diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppClientsConverter.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppClientsConverter.cs index 217705ea3..f65e69dcb 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppClientsConverter.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Json/AppClientsConverter.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.Linq; using Newtonsoft.Json; using Squidex.Infrastructure.Json.Newtonsoft; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs index 33bac4b6d..bc6d3075d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs @@ -54,10 +54,10 @@ namespace Squidex.Domain.Apps.Entities.Assets { case CreateAsset createAsset: { - await EnrichWithHashAndUploadAsync(createAsset, tempFile); - try { + await EnrichWithHashAndUploadAsync(createAsset, tempFile); + var ctx = contextProvider.Context.Clone().WithoutAssetEnrichment(); var existings = await assetQuery.QueryByHashAsync(ctx, createAsset.AppId.Id, createAsset.FileHash); @@ -80,6 +80,8 @@ namespace Squidex.Domain.Apps.Entities.Assets finally { await assetFileStore.DeleteAsync(tempFile); + + createAsset.File.Dispose(); } break; @@ -87,15 +89,17 @@ namespace Squidex.Domain.Apps.Entities.Assets case UpdateAsset updateAsset: { - await EnrichWithHashAndUploadAsync(updateAsset, tempFile); - try { + await EnrichWithHashAndUploadAsync(updateAsset, tempFile); + await UploadAsync(context, tempFile, updateAsset, null, false, next); } finally { await assetFileStore.DeleteAsync(tempFile); + + updateAsset.File.Dispose(); } break; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs index f1e7549b6..fdd1d867f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs @@ -5,8 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; +using SixLabors.ImageSharp; using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Infrastructure; @@ -25,15 +28,66 @@ namespace Squidex.Domain.Apps.Entities.Assets this.assetThumbnailGenerator = assetThumbnailGenerator; } + private sealed class TempAssetFile : AssetFile, IDisposable + { + public Stream Stream { get; } + + public TempAssetFile(AssetFile source) + : base(source.FileName, source.MimeType, source.FileSize) + { + var tempPath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName()); + + var tempStream = new FileStream(tempPath, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None, 4096, + FileOptions.DeleteOnClose); + + Stream = tempStream; + } + + public override void Dispose() + { + Stream.Dispose(); + } + + public override Stream OpenRead() + { + Stream.Position = 0; + + return Stream; + } + } + public async Task EnhanceAsync(UploadAssetCommand command, HashSet? tags) { - if (command.Type == AssetType.Unknown) + if (command.Type == AssetType.Unknown || command.Type == AssetType.Image) { + ImageInfo? imageInfo = null; + using (var uploadStream = command.File.OpenRead()) { - var imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream); + imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream); + } + + if (imageInfo != null) + { + var isSwapped = imageInfo.IsRotatedOrSwapped; + + if (isSwapped) + { + var tempFile = new TempAssetFile(command.File); + + using (var uploadStream = command.File.OpenRead()) + { + imageInfo = await assetThumbnailGenerator.FixOrientationAsync(uploadStream, tempFile.Stream); + } + + command.File.Dispose(); + command.File = tempFile; + } - if (imageInfo != null) + if (command.Type == AssetType.Unknown || isSwapped) { command.Type = AssetType.Image; diff --git a/backend/src/Squidex.Infrastructure/Assets/AssetFile.cs b/backend/src/Squidex.Infrastructure/Assets/AssetFile.cs index 4f5ef010f..cf17c74fb 100644 --- a/backend/src/Squidex.Infrastructure/Assets/AssetFile.cs +++ b/backend/src/Squidex.Infrastructure/Assets/AssetFile.cs @@ -10,17 +10,15 @@ using System.IO; namespace Squidex.Infrastructure.Assets { - public sealed class AssetFile + public abstract class AssetFile : IDisposable { - private readonly Func openAction; - public string FileName { get; } public string MimeType { get; } public long FileSize { get; } - public AssetFile(string fileName, string mimeType, long fileSize, Func openAction) + protected AssetFile(string fileName, string mimeType, long fileSize) { Guard.NotNullOrEmpty(fileName, nameof(fileName)); Guard.NotNullOrEmpty(mimeType, nameof(mimeType)); @@ -30,13 +28,12 @@ namespace Squidex.Infrastructure.Assets FileSize = fileSize; MimeType = mimeType; - - this.openAction = openAction; } - public Stream OpenRead() + public virtual void Dispose() { - return openAction(); } + + public abstract Stream OpenRead(); } } diff --git a/backend/src/Squidex.Infrastructure/Assets/DelegateAssetFile.cs b/backend/src/Squidex.Infrastructure/Assets/DelegateAssetFile.cs new file mode 100644 index 000000000..b152652f0 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/Assets/DelegateAssetFile.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; + +namespace Squidex.Infrastructure.Assets +{ + public sealed class DelegateAssetFile : AssetFile + { + private readonly Func openStream; + + public DelegateAssetFile(string fileName, string mimeType, long fileSize, Func openStream) + : base(fileName, mimeType, fileSize) + { + this.openStream = openStream; + } + + public override Stream OpenRead() + { + return openStream(); + } + } +} diff --git a/backend/src/Squidex.Infrastructure/Assets/IAssetThumbnailGenerator.cs b/backend/src/Squidex.Infrastructure/Assets/IAssetThumbnailGenerator.cs index 4d48f6fe4..b389cedad 100644 --- a/backend/src/Squidex.Infrastructure/Assets/IAssetThumbnailGenerator.cs +++ b/backend/src/Squidex.Infrastructure/Assets/IAssetThumbnailGenerator.cs @@ -14,6 +14,8 @@ namespace Squidex.Infrastructure.Assets { Task GetImageInfoAsync(Stream source); + Task FixOrientationAsync(Stream source, Stream destination); + Task CreateThumbnailAsync(Stream source, Stream destination, ResizeOptions options); } } diff --git a/backend/src/Squidex.Infrastructure/Assets/ImageInfo.cs b/backend/src/Squidex.Infrastructure/Assets/ImageInfo.cs index 2b3114cf3..fcc8d918d 100644 --- a/backend/src/Squidex.Infrastructure/Assets/ImageInfo.cs +++ b/backend/src/Squidex.Infrastructure/Assets/ImageInfo.cs @@ -13,13 +13,17 @@ namespace Squidex.Infrastructure.Assets public int PixelHeight { get; } - public ImageInfo(int pixelWidth, int pixelHeight) + public bool IsRotatedOrSwapped { get; } + + public ImageInfo(int pixelWidth, int pixelHeight, bool isRotatedOrSwapped) { Guard.GreaterThan(pixelWidth, 0, nameof(pixelWidth)); Guard.GreaterThan(pixelHeight, 0, nameof(pixelHeight)); PixelWidth = pixelWidth; PixelHeight = pixelHeight; + + IsRotatedOrSwapped = isRotatedOrSwapped; } } } diff --git a/backend/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs b/backend/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs index 5e12251a8..2c44d9340 100644 --- a/backend/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs +++ b/backend/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs @@ -10,6 +10,7 @@ using System.IO; using System.Threading.Tasks; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; using SixLabors.ImageSharp.Processing; using ISResizeMode = SixLabors.ImageSharp.Processing.ResizeMode; using ISResizeOptions = SixLabors.ImageSharp.Processing.ResizeOptions; @@ -22,67 +23,68 @@ namespace Squidex.Infrastructure.Assets.ImageSharp { Guard.NotNull(options, nameof(options)); - return Task.Run(() => + if (!options.IsValid) { - if (!options.IsValid) + source.CopyTo(destination); + + return Task.CompletedTask; + } + + var w = options.Width ?? 0; + var h = options.Height ?? 0; + + using (var image = Image.Load(source, out var format)) + { + var encoder = Configuration.Default.ImageFormatsManager.FindEncoder(format); + + if (encoder == null) { - source.CopyTo(destination); + throw new NotSupportedException(); + } - return; + if (options.Quality.HasValue && (encoder is JpegEncoder || !options.KeepFormat)) + { + encoder = new JpegEncoder { Quality = options.Quality.Value }; } - var w = options.Width ?? 0; - var h = options.Height ?? 0; + image.Mutate(x => x.AutoOrient()); - using (var sourceImage = Image.Load(source, out var format)) + if (w > 0 || h > 0) { - var encoder = Configuration.Default.ImageFormatsManager.FindEncoder(format); + var isCropUpsize = options.Mode == ResizeMode.CropUpsize; - if (options.Quality.HasValue && (encoder is JpegEncoder || !options.KeepFormat)) + if (!Enum.TryParse(options.Mode.ToString(), true, out var resizeMode)) { - encoder = new JpegEncoder { Quality = options.Quality.Value }; + resizeMode = ISResizeMode.Max; } - if (encoder == null) + if (isCropUpsize) { - throw new NotSupportedException(); + resizeMode = ISResizeMode.Crop; } - if (w > 0 || h > 0) + if (w >= image.Width && h >= image.Height && resizeMode == ISResizeMode.Crop && !isCropUpsize) { - var isCropUpsize = options.Mode == ResizeMode.CropUpsize; - - if (!Enum.TryParse(options.Mode.ToString(), true, out var resizeMode)) - { - resizeMode = ISResizeMode.Max; - } - - if (isCropUpsize) - { - resizeMode = ISResizeMode.Crop; - } - - if (w >= sourceImage.Width && h >= sourceImage.Height && resizeMode == ISResizeMode.Crop && !isCropUpsize) - { - resizeMode = ISResizeMode.BoxPad; - } - - var resizeOptions = new ISResizeOptions { Size = new Size(w, h), Mode = resizeMode }; - - if (options.FocusX.HasValue && options.FocusY.HasValue) - { - resizeOptions.CenterCoordinates = new PointF( - +(options.FocusX.Value / 2f) + 0.5f, - -(options.FocusY.Value / 2f) + 0.5f - ); - } - - sourceImage.Mutate(x => x.Resize(resizeOptions)); + resizeMode = ISResizeMode.BoxPad; } - sourceImage.Save(destination, encoder); + var resizeOptions = new ISResizeOptions { Size = new Size(w, h), Mode = resizeMode }; + + if (options.FocusX.HasValue && options.FocusY.HasValue) + { + resizeOptions.CenterCoordinates = new PointF( + +(options.FocusX.Value / 2f) + 0.5f, + -(options.FocusY.Value / 2f) + 0.5f + ); + } + + image.Mutate(x => x.Resize(resizeOptions)); } - }); + + image.Save(destination, encoder); + } + + return Task.CompletedTask; } public Task GetImageInfoAsync(Stream source) @@ -95,7 +97,7 @@ namespace Squidex.Infrastructure.Assets.ImageSharp if (image != null) { - result = new ImageInfo(image.Width, image.Height); + result = GetImageInfo(image); } } catch @@ -105,5 +107,38 @@ namespace Squidex.Infrastructure.Assets.ImageSharp return Task.FromResult(result); } + + public Task FixOrientationAsync(Stream source, Stream destination) + { + using (var image = Image.Load(source, out var format)) + { + var encoder = Configuration.Default.ImageFormatsManager.FindEncoder(format); + + if (encoder == null) + { + throw new NotSupportedException(); + } + + image.Mutate(x => x.AutoOrient()); + + image.Save(destination, encoder); + + return Task.FromResult(GetImageInfo(image)); + } + } + + private static ImageInfo GetImageInfo(IImageInfo image) + { + var isRotatedOrSwapped = false; + + if (image.Metadata.ExifProfile != null) + { + var value = image.Metadata.ExifProfile.GetValue(ExifTag.Orientation); + + isRotatedOrSwapped = value?.Value > 1; + } + + return new ImageInfo(image.Width, image.Height, isRotatedOrSwapped); + } } } diff --git a/backend/src/Squidex.Web/FileExtensions.cs b/backend/src/Squidex.Web/FileExtensions.cs index bcd2ad6dc..76e34b849 100644 --- a/backend/src/Squidex.Web/FileExtensions.cs +++ b/backend/src/Squidex.Web/FileExtensions.cs @@ -25,7 +25,7 @@ namespace Squidex.Web throw new ValidationException("File name is not defined."); } - return new AssetFile(formFile.FileName, formFile.ContentType, formFile.Length, formFile.OpenReadStream); + return new DelegateAssetFile(formFile.FileName, formFile.ContentType, formFile.Length, formFile.OpenReadStream); } } } diff --git a/backend/src/Squidex.Web/Pipeline/RequestExceptionMiddleware.cs b/backend/src/Squidex.Web/Pipeline/RequestExceptionMiddleware.cs index 3a4abba81..4c2d94b63 100644 --- a/backend/src/Squidex.Web/Pipeline/RequestExceptionMiddleware.cs +++ b/backend/src/Squidex.Web/Pipeline/RequestExceptionMiddleware.cs @@ -7,7 +7,6 @@ using System; using System.Threading.Tasks; -using Grpc.Core; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs index bc66ea0f8..ac39de1ce 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs @@ -67,19 +67,17 @@ namespace Squidex.Domain.Apps.Entities.Apps [Fact] public async Task Should_upload_image_to_store() { - var stream = new MemoryStream(); - - var file = new AssetFile("name.jpg", "image/jpg", 1024, () => stream); + var file = new NoopAssetFile(); var command = CreateCommand(new UploadAppImage { AppId = appId, File = file }); var context = CreateContextForCommand(command); - A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream)) - .Returns(new ImageInfo(100, 100)); + A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A._)) + .Returns(new ImageInfo(100, 100, false)); await sut.HandleAsync(context); - A.CallTo(() => appImageStore.UploadAsync(appId, stream, A._)) + A.CallTo(() => appImageStore.UploadAsync(appId, A._, A._)) .MustHaveHappened(); } @@ -88,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { var stream = new MemoryStream(); - var file = new AssetFile("name.jpg", "image/jpg", 1024, () => stream); + var file = new NoopAssetFile(); var command = CreateCommand(new UploadAppImage { AppId = appId, File = file }); var context = CreateContextForCommand(command); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs index e9d17f017..62a55be89 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppDomainObjectTests.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Threading.Tasks; using FakeItEasy; using Squidex.Domain.Apps.Core.Apps; @@ -18,7 +17,6 @@ using Squidex.Domain.Apps.Entities.Apps.State; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Events.Apps; using Squidex.Infrastructure; -using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Log; using Squidex.Shared.Users; @@ -148,7 +146,7 @@ namespace Squidex.Domain.Apps.Entities.Apps [Fact] public async Task UploadImage_should_create_events_and_update_image() { - var command = new UploadAppImage { File = new AssetFile("image.png", "image/png", 100, () => new MemoryStream()) }; + var command = new UploadAppImage { File = new NoopAssetFile() }; await ExecuteCreateAsync(); @@ -691,7 +689,7 @@ namespace Squidex.Domain.Apps.Entities.Apps private Task ExecuteUploadImage() { - return PublishAsync(new UploadAppImage { File = new AssetFile("image.png", "image/png", 100, () => new MemoryStream()) }); + return PublishAsync(new UploadAppImage { File = new NoopAssetFile() }); } private Task ExecuteAddPatternAsync() diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs index 5c0a5b760..8ae626840 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppTests.cs @@ -5,14 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.IO; using FakeItEasy; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; -using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Validation; using Squidex.Shared.Users; using Xunit; @@ -70,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards [Fact] public void CanUploadImage_should_not_throw_exception_if_app_name_is_valid() { - var command = new UploadAppImage { File = new AssetFile("file.png", "image/png", 100, () => new MemoryStream()) }; + var command = new UploadAppImage { File = new NoopAssetFile() }; GuardApp.CanUploadImage(command); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs index 97a00be7a..315ae9744 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Threading; using System.Threading.Tasks; using FakeItEasy; @@ -36,7 +35,6 @@ namespace Squidex.Domain.Apps.Entities.Assets private readonly IServiceProvider serviceProvider = A.Fake(); private readonly ITagService tagService = A.Fake(); private readonly Guid assetId = Guid.NewGuid(); - private readonly Stream stream = new MemoryStream(); private readonly AssetDomainObjectGrain asset; private readonly AssetFile file; private readonly Context requestContext = Context.Anonymous(); @@ -53,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Assets public AssetCommandMiddlewareTests() { - file = new AssetFile("my-image.png", "image/png", 1024, () => stream); + file = new NoopAssetFile(); var assetDomainObject = new AssetDomainObject(Store, tagService, assetQuery, A.Dummy()); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs index 0a7c4fb1f..0f8a5846b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetDomainObjectTests.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; using FakeItEasy; @@ -31,7 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Assets private readonly IAssetQueryService assetQuery = A.Fake(); private readonly Guid parentId = Guid.NewGuid(); private readonly Guid assetId = Guid.NewGuid(); - private readonly AssetFile file = new AssetFile("my-image.png", "image/png", 1024, () => new MemoryStream()); + private readonly AssetFile file = new NoopAssetFile(); private readonly AssetDomainObject sut; protected override Guid Id diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTagAssetMetadataSourceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTagAssetMetadataSourceTests.cs index 30cc6050f..d6592251d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTagAssetMetadataSourceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTagAssetMetadataSourceTests.cs @@ -127,7 +127,7 @@ namespace Squidex.Domain.Apps.Entities.Assets return new CreateAsset { - File = new AssetFile(file.Name, "mime", file.Length, file.OpenRead) + File = new DelegateAssetFile(file.Name, "mime", file.Length, file.OpenRead) }; } @@ -137,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities.Assets return new CreateAsset { - File = new AssetFile(name, "mime", stream.Length, () => stream) + File = new DelegateAssetFile(name, "mime", stream.Length, () => stream) }; } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTypeAssetMetadataSourceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTypeAssetMetadataSourceTests.cs index ea31b9f99..88f4e302f 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTypeAssetMetadataSourceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTypeAssetMetadataSourceTests.cs @@ -6,10 +6,9 @@ // ========================================================================== using System.Collections.Generic; -using System.IO; using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Assets.Commands; -using Squidex.Infrastructure.Assets; +using Squidex.Domain.Apps.Entities.TestHelpers; using Xunit; namespace Squidex.Domain.Apps.Entities.Assets @@ -34,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { var command = new CreateAsset { - File = new AssetFile("File.DOCX", "Mime", 100, () => new MemoryStream()) + File = new NoopAssetFile("File.DOCX") }; await sut.EnhanceAsync(command, tags); @@ -47,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Assets { var command = new CreateAsset { - File = new AssetFile("File", "Mime", 100, () => new MemoryStream()) + File = new NoopAssetFile("File") }; await sut.EnhanceAsync(command, tags); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs index 152bced7c..ee87d08c8 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs @@ -27,20 +27,20 @@ namespace Squidex.Domain.Apps.Entities.Assets public ImageAssetMetadataSourceTests() { - file = new AssetFile("MyImage.png", "image/png", 1024, () => stream); + file = new DelegateAssetFile("MyImage.png", "image/png", 1024, () => stream); sut = new ImageAssetMetadataSource(assetThumbnailGenerator); } [Fact] - public async Task Should_not_enhance_if_type_already_found() + public async Task Should_also_enhance_if_type_already_found() { var command = new CreateAsset { File = file, Type = AssetType.Image }; await sut.EnhanceAsync(command, tags); A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A._)) - .MustNotHaveHappened(); + .MustHaveHappened(); } [Fact] @@ -53,10 +53,49 @@ namespace Squidex.Domain.Apps.Entities.Assets Assert.Empty(tags); } + [Fact] + public async Task Should_get_dimensions_from_image_library() + { + var command = new CreateAsset { File = file }; + + A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream)) + .Returns(new ImageInfo(800, 600, false)); + + await sut.EnhanceAsync(command, tags); + + Assert.Equal(800, command.Metadata.GetPixelWidth()); + Assert.Equal(600, command.Metadata.GetPixelHeight()); + Assert.Equal(AssetType.Image, command.Type); + + A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_fix_image_if_oriented() + { + var command = new CreateAsset { File = file }; + + A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream)) + .Returns(new ImageInfo(600, 800, true)); + + A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, A._)) + .Returns(new ImageInfo(800, 600, true)); + + await sut.EnhanceAsync(command, tags); + + Assert.Equal(800, command.Metadata.GetPixelWidth()); + Assert.Equal(600, command.Metadata.GetPixelHeight()); + Assert.Equal(AssetType.Image, command.Type); + + A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, A._)) + .MustHaveHappened(); + } + [Fact] public async Task Should_add_image_tag_if_small() { - var command = new CreateAsset { Type = AssetType.Image }; + var command = new CreateAsset { File = file, Type = AssetType.Image }; command.Metadata.SetPixelWidth(100); command.Metadata.SetPixelWidth(100); @@ -70,7 +109,7 @@ namespace Squidex.Domain.Apps.Entities.Assets [Fact] public async Task Should_add_image_tag_if_medium() { - var command = new CreateAsset { Type = AssetType.Image }; + var command = new CreateAsset { File = file, Type = AssetType.Image }; command.Metadata.SetPixelWidth(800); command.Metadata.SetPixelWidth(600); @@ -84,7 +123,7 @@ namespace Squidex.Domain.Apps.Entities.Assets [Fact] public async Task Should_add_image_tag_if_large() { - var command = new CreateAsset { Type = AssetType.Image }; + var command = new CreateAsset { File = file, Type = AssetType.Image }; command.Metadata.SetPixelWidth(1200); command.Metadata.SetPixelWidth(1400); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/NoopAssetFile.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/NoopAssetFile.cs new file mode 100644 index 000000000..d26bb381e --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/NoopAssetFile.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.IO; +using Squidex.Infrastructure.Assets; + +namespace Squidex.Domain.Apps.Entities.TestHelpers +{ + public sealed class NoopAssetFile : AssetFile + { + public NoopAssetFile(string fileName = "image.png", string mimeType = "image/png", long fileSize = 1024) + : base(fileName, mimeType, fileSize) + { + } + + public override Stream OpenRead() + { + return new MemoryStream(); + } + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/Assets/ImageSharpAssetThumbnailGeneratorTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Assets/ImageSharpAssetThumbnailGeneratorTests.cs index ce72ed25c..d550e6f25 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Assets/ImageSharpAssetThumbnailGeneratorTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Assets/ImageSharpAssetThumbnailGeneratorTests.cs @@ -64,6 +64,18 @@ namespace Squidex.Infrastructure.Assets Assert.True(target.Length < source.Length); } + [Fact] + public async Task Should_auto_orient_image() + { + var source = GetRotatedJpeg(); + + var imageInfo = await sut.FixOrientationAsync(source, target); + + Assert.Equal(135, imageInfo.PixelHeight); + Assert.Equal(600, imageInfo.PixelWidth); + Assert.False(imageInfo.IsRotatedOrSwapped); + } + [Fact] public async Task Should_return_image_information_if_image_is_valid() { @@ -73,6 +85,19 @@ namespace Squidex.Infrastructure.Assets Assert.Equal(600, imageInfo!.PixelHeight); Assert.Equal(600, imageInfo!.PixelWidth); + Assert.False(imageInfo.IsRotatedOrSwapped); + } + + [Fact] + public async Task Should_return_image_information_if_rotated() + { + var source = GetRotatedJpeg(); + + var imageInfo = await sut.GetImageInfoAsync(source); + + Assert.Equal(600, imageInfo!.PixelHeight); + Assert.Equal(135, imageInfo!.PixelWidth); + Assert.True(imageInfo.IsRotatedOrSwapped); } [Fact] @@ -94,5 +119,10 @@ namespace Squidex.Infrastructure.Assets { return GetType().Assembly.GetManifestResourceStream("Squidex.Infrastructure.Assets.Images.logo.jpg")!; } + + private Stream GetRotatedJpeg() + { + return GetType().Assembly.GetManifestResourceStream("Squidex.Infrastructure.Assets.Images.logo-wide-rotated.jpg")!; + } } } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Assets/Images/logo-wide-rotated.jpg b/backend/tests/Squidex.Infrastructure.Tests/Assets/Images/logo-wide-rotated.jpg new file mode 100644 index 000000000..0679aba18 Binary files /dev/null and b/backend/tests/Squidex.Infrastructure.Tests/Assets/Images/logo-wide-rotated.jpg differ diff --git a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj index 42be07719..6db6b4dd8 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj +++ b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj @@ -7,6 +7,7 @@ enable + @@ -41,6 +42,7 @@ + diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/RequestExceptionMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/RequestExceptionMiddlewareTests.cs index 4500ce80d..ed18f50fb 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/RequestExceptionMiddlewareTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/RequestExceptionMiddlewareTests.cs @@ -7,15 +7,11 @@ using System; using System.Threading.Tasks; -using Elasticsearch.Net; using FakeItEasy; -using GraphQL; -using Grpc.Core.Logging; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; -using Squidex.Infrastructure; using Squidex.Infrastructure.Log; using Xunit; diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs index f81db98c4..7e07cc505 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs @@ -82,6 +82,21 @@ namespace TestSuite.ApiTests Assert.Equal(AssetType.Image, asset.Type); } + [Fact] + public async Task Should_fix_orientation() + { + var asset = await _.UploadFileAsync("Assets/logo-wide-rotated.jpg", "image/jpg"); + + // Should parse image metadata and fix orientation. + Assert.True(asset.IsImage); + Assert.Equal(135, asset.PixelHeight); + Assert.Equal(600, asset.PixelWidth); + Assert.Equal(135L, asset.Metadata["pixelHeight"]); + Assert.Equal(600L, asset.Metadata["pixelWidth"]); + Assert.Equal(79L, asset.Metadata["imageQuality"]); + Assert.Equal(AssetType.Image, asset.Type); + } + [Fact] public async Task Should_upload_audio_mp3() { diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/logo-wide-rotated.jpg b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/logo-wide-rotated.jpg new file mode 100644 index 000000000..0679aba18 Binary files /dev/null and b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/logo-wide-rotated.jpg differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj index cf147d566..375a59eaf 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj +++ b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj @@ -22,13 +22,13 @@ - - - PreserveNewest + + PreserveNewest + PreserveNewest