diff --git a/nuget.config b/nuget.config new file mode 100644 index 000000000000..6aa8697b8fe9 --- /dev/null +++ b/nuget.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs b/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs index 8232746ead3e..77561713b2de 100644 --- a/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs @@ -4,15 +4,20 @@ namespace Umbraco.Cms.Core.Configuration.Models; /// -/// Typed configuration options for imaging settings. +/// Typed configuration options for imaging settings. /// [UmbracoOptions(Constants.Configuration.ConfigImaging)] public class ImagingSettings { /// - /// Gets or sets a value for imaging cache settings. + /// Gets or sets a value for the Hash-based Message Authentication Code (HMAC) secret key for request authentication. /// - public ImagingCacheSettings Cache { get; set; } = new(); + public byte[]? HMACSecretKey { get; set; } + + /// + /// Gets or sets a value for imaging cache settings. + /// + public ImagingCacheSettings Cache { get; set; } = new ImagingCacheSettings(); /// /// Gets or sets a value for imaging resize settings. diff --git a/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs b/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs index 8f7901b2b4f1..b0b9e7a1aaea 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ImagesController.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Web; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; @@ -10,13 +11,13 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers; /// -/// A controller used to return images for media +/// A controller used to return images for media. /// [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] public class ImagesController : UmbracoAuthorizedApiController { - private readonly IImageUrlGenerator _imageUrlGenerator; private readonly MediaFileManager _mediaFileManager; + private readonly IImageUrlGenerator _imageUrlGenerator; public ImagesController( MediaFileManager mediaFileManager, @@ -27,26 +28,26 @@ public ImagesController( } /// - /// Gets the big thumbnail image for the original image path + /// Gets the big thumbnail image for the original image path. /// /// /// /// - /// If there is no original image is found then this will return not found. + /// If there is no original image is found then this will return not found. /// - public IActionResult GetBigThumbnail(string originalImagePath) => - string.IsNullOrWhiteSpace(originalImagePath) - ? Ok() - : GetResized(originalImagePath, 500); + public IActionResult GetBigThumbnail(string originalImagePath) + => string.IsNullOrWhiteSpace(originalImagePath) + ? Ok() + : GetResized(originalImagePath, 500); /// - /// Gets a resized image for the image at the given path + /// Gets a resized image for the image at the given path. /// /// /// /// /// - /// If there is no media, image property or image file is found then this will return not found. + /// If there is no media, image property or image file is found then this will return not found. /// public IActionResult GetResized(string imagePath, int width) { @@ -54,7 +55,6 @@ public IActionResult GetResized(string imagePath, int width) // We cannot use the WebUtility, as we only want to encode the path, and not the entire string var encodedImagePath = HttpUtility.UrlPathEncode(imagePath); - var ext = Path.GetExtension(encodedImagePath); // check if imagePath is local to prevent open redirect @@ -69,13 +69,13 @@ public IActionResult GetResized(string imagePath, int width) return NotFound(); } - // redirect to ImageProcessor thumbnail with rnd generated from last modified time of original media file + // Redirect to thumbnail with cache buster value generated from last modified time of original media file DateTimeOffset? imageLastModified = null; try { imageLastModified = _mediaFileManager.FileSystem.GetLastModified(imagePath); } - catch (Exception) + catch { // if we get an exception here it's probably because the image path being requested is an image that doesn't exist // in the local media file system. This can happen if someone is storing an absolute path to an image online, which @@ -83,23 +83,26 @@ public IActionResult GetResized(string imagePath, int width) // so ignore and we won't set a last modified date. } - var rnd = imageLastModified.HasValue ? $"&rnd={imageLastModified:yyyyMMddHHmmss}" : null; + var cacheBusterValue = imageLastModified.HasValue ? imageLastModified.Value.ToFileTime().ToString("x", CultureInfo.InvariantCulture) : null; var imageUrl = _imageUrlGenerator.GetImageUrl(new ImageUrlGenerationOptions(encodedImagePath) { Width = width, ImageCropMode = ImageCropMode.Max, - CacheBusterValue = rnd + CacheBusterValue = cacheBusterValue }); + if (Url.IsLocalUrl(imageUrl)) { return new LocalRedirectResult(imageUrl, false); } - - return Unauthorized(); + else + { + return Unauthorized(); + } } /// - /// Gets a processed image for the image at the given path + /// Gets a processed image for the image at the given path /// /// /// @@ -107,14 +110,9 @@ public IActionResult GetResized(string imagePath, int width) /// /// /// - /// - /// - /// - /// - /// /// /// - /// If there is no media, image property or image file is found then this will return not found. + /// If there is no media, image property or image file is found then this will return not found. /// public string? GetProcessedImageUrl( string imagePath, @@ -123,7 +121,7 @@ public IActionResult GetResized(string imagePath, int width) decimal? focalPointLeft = null, decimal? focalPointTop = null, ImageCropMode mode = ImageCropMode.Max, - string cacheBusterValue = "", + string cacheBusterValue = "", // TODO Change to: string? cacheBusterValue = null decimal? cropX1 = null, decimal? cropX2 = null, decimal? cropY1 = null, @@ -139,13 +137,11 @@ public IActionResult GetResized(string imagePath, int width) if (focalPointLeft.HasValue && focalPointTop.HasValue) { - options.FocalPoint = - new ImageUrlGenerationOptions.FocalPointPosition(focalPointLeft.Value, focalPointTop.Value); + options.FocalPoint = new ImageUrlGenerationOptions.FocalPointPosition(focalPointLeft.Value, focalPointTop.Value); } else if (cropX1.HasValue && cropX2.HasValue && cropY1.HasValue && cropY2.HasValue) { - options.Crop = - new ImageUrlGenerationOptions.CropCoordinates(cropX1.Value, cropY1.Value, cropX2.Value, cropY2.Value); + options.Crop = new ImageUrlGenerationOptions.CropCoordinates(cropX1.Value, cropY1.Value, cropX2.Value, cropY2.Value); } return _imageUrlGenerator.GetImageUrl(options); diff --git a/src/Umbraco.Web.Common/DependencyInjection/ConfigureImageSharpMiddlewareOptions.cs b/src/Umbraco.Web.Common/DependencyInjection/ConfigureImageSharpMiddlewareOptions.cs index 2b372992ccdd..64e6ca19a68e 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/ConfigureImageSharpMiddlewareOptions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/ConfigureImageSharpMiddlewareOptions.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Headers; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using SixLabors.ImageSharp; @@ -7,11 +9,12 @@ using SixLabors.ImageSharp.Web.Middleware; using SixLabors.ImageSharp.Web.Processors; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Media; namespace Umbraco.Cms.Web.Common.DependencyInjection; /// -/// Configures the ImageSharp middleware options. +/// Configures the ImageSharp middleware options. /// /// public sealed class ConfigureImageSharpMiddlewareOptions : IConfigureOptions @@ -20,7 +23,7 @@ public sealed class ConfigureImageSharpMiddlewareOptions : IConfigureOptions - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The ImageSharp configuration. /// The Umbraco imaging settings. @@ -35,6 +38,7 @@ public void Configure(ImageSharpMiddlewareOptions options) { options.Configuration = _configuration; + options.HMACSecretKey = _imagingSettings.HMACSecretKey; options.BrowserMaxAge = _imagingSettings.Cache.BrowserMaxAge; options.CacheMaxAge = _imagingSettings.Cache.CacheMaxAge; options.CacheHashLength = _imagingSettings.Cache.CacheHashLength; @@ -42,22 +46,19 @@ public void Configure(ImageSharpMiddlewareOptions options) // Use configurable maximum width and height options.OnParseCommandsAsync = context => { - if (context.Commands.Count == 0) + if (context.Commands.Count == 0 || _imagingSettings.HMACSecretKey?.Length > 0) { + // Nothing to parse or using HMAC authentication return Task.CompletedTask; } - var width = context.Parser.ParseValue( - context.Commands.GetValueOrDefault(ResizeWebProcessor.Width), - context.Culture); + int width = context.Parser.ParseValue(context.Commands.GetValueOrDefault(ResizeWebProcessor.Width), context.Culture); if (width <= 0 || width > _imagingSettings.Resize.MaxWidth) { context.Commands.Remove(ResizeWebProcessor.Width); } - var height = context.Parser.ParseValue( - context.Commands.GetValueOrDefault(ResizeWebProcessor.Height), - context.Culture); + int height = context.Parser.ParseValue(context.Commands.GetValueOrDefault(ResizeWebProcessor.Height), context.Culture); if (height <= 0 || height > _imagingSettings.Resize.MaxHeight) { context.Commands.Remove(ResizeWebProcessor.Height); @@ -73,11 +74,16 @@ public void Configure(ImageSharpMiddlewareOptions options) { ResponseHeaders headers = context.Response.GetTypedHeaders(); - CacheControlHeaderValue cacheControl = - headers.CacheControl ?? new CacheControlHeaderValue { Public = true }; - cacheControl.MustRevalidate = false; // ImageSharp enables this by default + CacheControlHeaderValue cacheControl = headers.CacheControl ?? new CacheControlHeaderValue() + { + Public = true + }; + + // ImageSharp enables cache revalidation by default, so disable and add immutable directive + cacheControl.MustRevalidate = false; cacheControl.Extensions.Add(new NameValueHeaderValue("immutable")); + // Set updated value headers.CacheControl = cacheControl; } diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.ImageSharp.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.ImageSharp.cs index e5e642f4a3f7..f67858c4b7de 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.ImageSharp.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilder.ImageSharp.cs @@ -15,28 +15,23 @@ namespace Umbraco.Extensions; public static partial class UmbracoBuilderExtensions { /// - /// Adds Image Sharp with Umbraco settings + /// Adds ImageSharp with Umbraco settings. /// public static IServiceCollection AddUmbracoImageSharp(this IUmbracoBuilder builder) { builder.Services.AddSingleton(); + // Add ImageSharp, replace default image provider and add custom processors builder.Services.AddImageSharp() - - // Replace default image provider .ClearProviders() .AddProvider() - - // Add custom processors .AddProcessor(); // Configure middleware - builder.Services - .AddTransient, ConfigureImageSharpMiddlewareOptions>(); + builder.Services.AddTransient, ConfigureImageSharpMiddlewareOptions>(); // Configure cache options - builder.Services - .AddTransient, ConfigurePhysicalFileSystemCacheOptions>(); + builder.Services.AddTransient, ConfigurePhysicalFileSystemCacheOptions>(); return builder.Services; } diff --git a/src/Umbraco.Web.Common/Extensions/ImageCropperTemplateCoreExtensions.cs b/src/Umbraco.Web.Common/Extensions/ImageCropperTemplateCoreExtensions.cs index 78a01dca2d00..676b05317e51 100644 --- a/src/Umbraco.Web.Common/Extensions/ImageCropperTemplateCoreExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/ImageCropperTemplateCoreExtensions.cs @@ -558,7 +558,7 @@ public static class ImageCropperTemplateCoreExtensions } var cacheBusterValue = - cacheBuster ? mediaItem.UpdateDate.ToFileTimeUtc().ToString(CultureInfo.InvariantCulture) : null; + cacheBuster ? mediaItem.UpdateDate.ToFileTimeUtc().ToString("x", CultureInfo.InvariantCulture) : null; return GetCropUrl( mediaItemUrl, diff --git a/src/Umbraco.Web.Common/Media/ImageSharpImageUrlGenerator.cs b/src/Umbraco.Web.Common/Media/ImageSharpImageUrlGenerator.cs index b1b96ddc1008..d442bee97111 100644 --- a/src/Umbraco.Web.Common/Media/ImageSharpImageUrlGenerator.cs +++ b/src/Umbraco.Web.Common/Media/ImageSharpImageUrlGenerator.cs @@ -1,39 +1,57 @@ using System.Globalization; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Primitives; using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Web; using SixLabors.ImageSharp.Web.Processors; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.ImageProcessors; using static Umbraco.Cms.Core.Models.ImageUrlGenerationOptions; namespace Umbraco.Cms.Web.Common.Media; /// -/// Exposes a method that generates an image URL based on the specified options that can be processed by ImageSharp. +/// Exposes a method that generates an image URL based on the specified options that can be processed by ImageSharp. /// /// public class ImageSharpImageUrlGenerator : IImageUrlGenerator { + private readonly ImageSharpRequestAuthorizationUtilities? _requestAuthorizationUtilities; + /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The ImageSharp configuration. + /// Contains helpers that allow authorization of image requests. + public ImageSharpImageUrlGenerator(Configuration configuration, ImageSharpRequestAuthorizationUtilities? requestAuthorizationUtilities) + : this(configuration.ImageFormats.SelectMany(f => f.FileExtensions).ToArray(), requestAuthorizationUtilities) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The ImageSharp configuration. + [Obsolete("Use ctor with all params - This will be removed in Umbraco 12.")] public ImageSharpImageUrlGenerator(Configuration configuration) - : this(configuration.ImageFormats.SelectMany(f => f.FileExtensions).ToArray()) - { - } + : this(configuration, StaticServiceProvider.Instance.GetService()) + { } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The supported image file types/extensions. + /// Contains helpers that allow authorization of image requests. /// - /// This constructor is only used for testing. + /// This constructor is only used for testing. /// - internal ImageSharpImageUrlGenerator(IEnumerable supportedImageFileTypes) => + internal ImageSharpImageUrlGenerator(IEnumerable supportedImageFileTypes, ImageSharpRequestAuthorizationUtilities? requestAuthorizationUtilities = null) + { SupportedImageFileTypes = supportedImageFileTypes; + _requestAuthorizationUtilities = requestAuthorizationUtilities; + } /// public IEnumerable SupportedImageFileTypes { get; } @@ -49,47 +67,44 @@ internal ImageSharpImageUrlGenerator(IEnumerable supportedImageFileTypes var queryString = new Dictionary(); Dictionary furtherOptions = QueryHelpers.ParseQuery(options.FurtherOptions); - if (options.Crop is not null) + if (options.Crop is CropCoordinates crop) { - CropCoordinates? crop = options.Crop; - queryString.Add( - CropWebProcessor.Coordinates, - FormattableString.Invariant($"{crop.Left},{crop.Top},{crop.Right},{crop.Bottom}")); + queryString.Add(CropWebProcessor.Coordinates, FormattableString.Invariant($"{crop.Left},{crop.Top},{crop.Right},{crop.Bottom}")); } - if (options.FocalPoint is not null) + if (options.FocalPoint is FocalPointPosition focalPoint) { - queryString.Add(ResizeWebProcessor.Xy, FormattableString.Invariant($"{options.FocalPoint.Left},{options.FocalPoint.Top}")); + queryString.Add(ResizeWebProcessor.Xy, FormattableString.Invariant($"{focalPoint.Left},{focalPoint.Top}")); } - if (options.ImageCropMode is not null) + if (options.ImageCropMode is ImageCropMode imageCropMode) { - queryString.Add(ResizeWebProcessor.Mode, options.ImageCropMode.ToString()?.ToLowerInvariant()); + queryString.Add(ResizeWebProcessor.Mode, imageCropMode.ToString().ToLowerInvariant()); } - if (options.ImageCropAnchor is not null) + if (options.ImageCropAnchor is ImageCropAnchor imageCropAnchor) { - queryString.Add(ResizeWebProcessor.Anchor, options.ImageCropAnchor.ToString()?.ToLowerInvariant()); + queryString.Add(ResizeWebProcessor.Anchor, imageCropAnchor.ToString().ToLowerInvariant()); } - if (options.Width is not null) + if (options.Width is int width) { - queryString.Add(ResizeWebProcessor.Width, options.Width?.ToString(CultureInfo.InvariantCulture)); + queryString.Add(ResizeWebProcessor.Width, width.ToString(CultureInfo.InvariantCulture)); } - if (options.Height is not null) + if (options.Height is int height) { - queryString.Add(ResizeWebProcessor.Height, options.Height?.ToString(CultureInfo.InvariantCulture)); + queryString.Add(ResizeWebProcessor.Height, height.ToString(CultureInfo.InvariantCulture)); } - + if (furtherOptions.Remove(FormatWebProcessor.Format, out StringValues format)) { - queryString.Add(FormatWebProcessor.Format, format[0]); + queryString.Add(FormatWebProcessor.Format, format.ToString()); } - if (options.Quality is not null) + if (options.Quality is int quality) { - queryString.Add(QualityWebProcessor.Quality, options.Quality?.ToString(CultureInfo.InvariantCulture)); + queryString.Add(QualityWebProcessor.Quality, quality.ToString(CultureInfo.InvariantCulture)); } foreach (KeyValuePair kvp in furtherOptions) @@ -97,9 +112,18 @@ internal ImageSharpImageUrlGenerator(IEnumerable supportedImageFileTypes queryString.Add(kvp.Key, kvp.Value); } - if (options.CacheBusterValue is not null && !string.IsNullOrWhiteSpace(options.CacheBusterValue)) + if (options.CacheBusterValue is string cacheBusterValue && !string.IsNullOrEmpty(cacheBusterValue)) + { + queryString.Add("v", cacheBusterValue); + } + + if (_requestAuthorizationUtilities is not null) { - queryString.Add("rnd", options.CacheBusterValue); + var uri = QueryHelpers.AddQueryString(options.ImageUrl, queryString); + if (_requestAuthorizationUtilities.ComputeHMAC(uri, CommandHandling.Sanitize) is string token && !string.IsNullOrEmpty(token)) + { + queryString.Add(ImageSharpRequestAuthorizationUtilities.TokenCommand, token); + } } return QueryHelpers.AddQueryString(options.ImageUrl, queryString); diff --git a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj index 8f7ea5f93578..db9ab1897d6d 100644 --- a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj +++ b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/Umbraco.Web.UI.Client/src/common/services/mediahelper.service.js b/src/Umbraco.Web.UI.Client/src/common/services/mediahelper.service.js index e98a597e764b..14de3bb1c4a7 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/mediahelper.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/mediahelper.service.js @@ -313,7 +313,7 @@ function mediaHelper(umbRequestHelper, $http, $log) { var thumbnailUrl = umbRequestHelper.getApiUrl( "imagesApiBaseUrl", "GetBigThumbnail", - [{ originalImagePath: imagePath }]) + '&rnd=' + Math.random(); + [{ originalImagePath: imagePath }]); return thumbnailUrl; }, diff --git a/src/Umbraco.Web.UI.Client/src/views/components/blockcard/umbBlockCard.component.js b/src/Umbraco.Web.UI.Client/src/views/components/blockcard/umbBlockCard.component.js index bb4c8c2a0877..1a8cfa9a38ab 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/blockcard/umbBlockCard.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/components/blockcard/umbBlockCard.component.js @@ -14,7 +14,7 @@ } }); - function BlockCardController($scope, umbRequestHelper) { + function BlockCardController($scope, umbRequestHelper, mediaHelper) { const vm = this; vm.styleBackgroundImage = "none"; @@ -45,8 +45,10 @@ var path = umbRequestHelper.convertVirtualToAbsolutePath(vm.blockConfigModel.thumbnail); if (path.toLowerCase().endsWith(".svg") === false) { - path += "?width=400"; + + path = mediaHelper.getThumbnailFromPath(path); } + vm.styleBackgroundImage = `url('${path}')`; }; diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/fileupload/fileupload.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/fileupload/fileupload.controller.js index b4d59c683c73..5d4776b8f426 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/fileupload/fileupload.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/fileupload/fileupload.controller.js @@ -53,16 +53,8 @@ // they contain different data structures so if we need to query against it we need to be aware of this. mediaHelper.registerFileResolver("Umbraco.UploadField", function (property, entity, thumbnail) { if (thumbnail) { - if (mediaHelper.detectIfImageByExtension(property.value)) { - //get default big thumbnail from image processor - var thumbnailUrl = property.value + "?width=500&rnd=" + moment(entity.updateDate).format("YYYYMMDDHHmmss"); - return thumbnailUrl; - } - else { - return null; - } - } - else { + return mediaHelper.getThumbnailFromPath(property.value); + } else { return property.value; } }); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/editors/media.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/editors/media.controller.js index 81a548a1169a..71519c524544 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/editors/media.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/editors/media.controller.js @@ -1,10 +1,10 @@ angular.module("umbraco") .controller("Umbraco.PropertyEditors.Grid.MediaController", - function ($scope, userService, editorService, localizationService) { + function ($scope, userService, editorService, localizationService, mediaHelper) { $scope.control.icon = $scope.control.icon || 'icon-picture'; - $scope.thumbnailUrl = getThumbnailUrl(); + updateThumbnailUrl(); if (!$scope.model.config.startNodeId) { if ($scope.model.config.ignoreUserStartNodes === true) { @@ -61,40 +61,31 @@ angular.module("umbraco") /** * */ - function getThumbnailUrl() { - + function updateThumbnailUrl() { if ($scope.control.value && $scope.control.value.image) { - var url = $scope.control.value.image; - - if ($scope.control.editor.config && $scope.control.editor.config.size){ - if ($scope.control.value.coordinates) { - // New way, crop by percent must come before width/height. - var coords = $scope.control.value.coordinates; - url += `?cc=${coords.x1},${coords.y1},${coords.x2},${coords.y2}`; - } else { - // Here in order not to break existing content where focalPoint were used. - if ($scope.control.value.focalPoint) { - url += `?rxy=${$scope.control.value.focalPoint.left},${$scope.control.value.focalPoint.top}`; - } else { - // Prevent black padding and no crop when focal point not set / changed from default - url += '?rxy=0.5,0.5'; - } - } - - url += '&width=' + $scope.control.editor.config.size.width; - url += '&height=' + $scope.control.editor.config.size.height; + var options = { + width: 800 + }; + + if ($scope.control.value.coordinates) { + // Use crop + options.crop = $scope.control.value.coordinates; + } else if ($scope.control.value.focalPoint) { + // Otherwise use focal point + options.focalPoint = $scope.control.value.focalPoint; } - // set default size if no crop present (moved from the view) - if (url.includes('?') === false) - { - url += '?width=800' + if ($scope.control.editor.config && $scope.control.editor.config.size) { + options.width = $scope.control.editor.config.size.width; + options.height = $scope.control.editor.config.size.height; } - return url; + mediaHelper.getProcessedImageUrl($scope.control.value.image, options).then(imageUrl => { + $scope.thumbnailUrl = imageUrl; + }); + } else { + $scope.thumbnailUrl = null; } - - return null; } /** @@ -113,6 +104,7 @@ angular.module("umbraco") caption: selectedImage.caption, altText: selectedImage.altText }; - $scope.thumbnailUrl = getThumbnailUrl(); + + updateThumbnailUrl(); } }); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/imagecropper/imagecropper.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/imagecropper/imagecropper.controller.js index b5131e993875..453347bc1bb6 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/imagecropper/imagecropper.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/imagecropper/imagecropper.controller.js @@ -236,9 +236,8 @@ angular.module('umbraco') if (property.value && property.value.src) { if (thumbnail === true) { - return property.value.src + "?width=500"; - } - else { + return mediaHelper.getThumbnailFromPath(property.value.src); + } else { return property.value.src; } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Media/ImageSharpImageUrlGeneratorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Media/ImageSharpImageUrlGeneratorTests.cs index 093e9b075c27..09aee65b2384 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Media/ImageSharpImageUrlGeneratorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Media/ImageSharpImageUrlGeneratorTests.cs @@ -1,7 +1,15 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using NUnit.Framework; +using SixLabors.ImageSharp.Web; +using SixLabors.ImageSharp.Web.Commands; +using SixLabors.ImageSharp.Web.Commands.Converters; +using SixLabors.ImageSharp.Web.Middleware; +using SixLabors.ImageSharp.Web.Processors; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Web.Common.Media; @@ -11,56 +19,62 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Media; public class ImageSharpImageUrlGeneratorTests { private const string MediaPath = "/media/1005/img_0671.jpg"; - - private static readonly ImageUrlGenerationOptions.CropCoordinates s_crop = new(0.58729977382575338m, 0.055768992440203169m, 0m, 0.32457553600198386m); - - private static readonly ImageUrlGenerationOptions.FocalPointPosition s_focus1 = new(0.96m, 0.80827067669172936m); - private static readonly ImageUrlGenerationOptions.FocalPointPosition s_focus2 = new(0.4275m, 0.41m); - private static readonly ImageSharpImageUrlGenerator s_generator = new(new string[0]); + private static readonly ImageUrlGenerationOptions.CropCoordinates _crop = new ImageUrlGenerationOptions.CropCoordinates(0.58729977382575338m, 0.055768992440203169m, 0m, 0.32457553600198386m); + private static readonly ImageUrlGenerationOptions.FocalPointPosition _focus1 = new ImageUrlGenerationOptions.FocalPointPosition(0.96m, 0.80827067669172936m); + private static readonly ImageUrlGenerationOptions.FocalPointPosition _focus2 = new ImageUrlGenerationOptions.FocalPointPosition(0.4275m, 0.41m); + private static readonly ImageSharpImageUrlGenerator _generator = new ImageSharpImageUrlGenerator(new string[0]); [Test] public void GetImageUrl_CropAliasTest() { - var urlString = - s_generator.GetImageUrl( - new ImageUrlGenerationOptions(MediaPath) { Crop = s_crop, Width = 100, Height = 100 }); - Assert.AreEqual( - MediaPath + "?cc=0.58729977382575338,0.055768992440203169,0,0.32457553600198386&width=100&height=100", - urlString); + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + Crop = _crop, + Width = 100, + Height = 100, + }); + + Assert.AreEqual(MediaPath + "?cc=0.58729977382575338,0.055768992440203169,0,0.32457553600198386&width=100&height=100", urlString); } [Test] public void GetImageUrl_WidthHeightTest() { - var urlString = - s_generator.GetImageUrl( - new ImageUrlGenerationOptions(MediaPath) { FocalPoint = s_focus1, Width = 200, Height = 300 }); + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + FocalPoint = _focus1, + Width = 200, + Height = 300, + }); + Assert.AreEqual(MediaPath + "?rxy=0.96,0.80827067669172936&width=200&height=300", urlString); } [Test] public void GetImageUrl_FocalPointTest() { - var urlString = - s_generator.GetImageUrl( - new ImageUrlGenerationOptions(MediaPath) { FocalPoint = s_focus1, Width = 100, Height = 100 }); + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + FocalPoint = _focus1, + Width = 100, + Height = 100, + }); + Assert.AreEqual(MediaPath + "?rxy=0.96,0.80827067669172936&width=100&height=100", urlString); } [Test] public void GetImageUrlFurtherOptionsTest() { - var urlString = s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) { - FocalPoint = s_focus1, + FocalPoint = _focus1, Width = 200, Height = 300, FurtherOptions = "&filter=comic&roundedcorners=radius-26|bgcolor-fff", }); - Assert.AreEqual( - MediaPath + - "?rxy=0.96,0.80827067669172936&width=200&height=300&filter=comic&roundedcorners=radius-26%7Cbgcolor-fff", - urlString); + + Assert.AreEqual(MediaPath + "?rxy=0.96,0.80827067669172936&width=200&height=300&filter=comic&roundedcorners=radius-26%7Cbgcolor-fff", urlString); } [Test] @@ -91,90 +105,91 @@ public void GetImageUrlFurtherOptionsWithModeAndQualityTest() } /// - /// Test that if options is null, the generated image URL is also null. + /// Test that if options is null, the generated image URL is also null. /// [Test] public void GetImageUrlNullOptionsTest() { - var urlString = s_generator.GetImageUrl(null); + var urlString = _generator.GetImageUrl(null); Assert.AreEqual(null, urlString); } /// - /// Test that if the image URL is null, the generated image URL is also null. + /// Test that if the image URL is null, the generated image URL is also null. /// [Test] public void GetImageUrlNullTest() { - var urlString = s_generator.GetImageUrl(new ImageUrlGenerationOptions(null)); + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(null)); Assert.AreEqual(null, urlString); } /// - /// Test that if the image URL is empty, the generated image URL is empty. + /// Test that if the image URL is empty, the generated image URL is empty. /// [Test] public void GetImageUrlEmptyTest() { - var urlString = s_generator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty)); + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty)); Assert.AreEqual(string.Empty, urlString); } /// - /// Test the GetImageUrl method on the ImageCropDataSet Model + /// Test the GetImageUrl method on the ImageCropDataSet Model /// [Test] public void GetBaseCropUrlFromModelTest() { - var urlString = - s_generator.GetImageUrl( - new ImageUrlGenerationOptions(string.Empty) { Crop = s_crop, Width = 100, Height = 100 }); - Assert.AreEqual( - "?cc=0.58729977382575338,0.055768992440203169,0,0.32457553600198386&width=100&height=100", - urlString); + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty) + { + Crop = _crop, + Width = 100, + Height = 100, + }); + + Assert.AreEqual("?cc=0.58729977382575338,0.055768992440203169,0,0.32457553600198386&width=100&height=100", urlString); } /// - /// Test that if Crop mode is specified as anything other than Crop the image doesn't use the crop + /// Test that if Crop mode is specified as anything other than Crop the image doesn't use the crop /// [Test] public void GetImageUrl_SpecifiedCropModeTest() { - var urlStringMin = - s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) - { - ImageCropMode = ImageCropMode.Min, - Width = 300, - Height = 150, - }); - var urlStringBoxPad = - s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) - { - ImageCropMode = ImageCropMode.BoxPad, - Width = 300, - Height = 150, - }); - var urlStringPad = - s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) - { - ImageCropMode = ImageCropMode.Pad, - Width = 300, - Height = 150, - }); - var urlStringMax = - s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) - { - ImageCropMode = ImageCropMode.Max, - Width = 300, - Height = 150, - }); - var urlStringStretch = - s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) - { - ImageCropMode = ImageCropMode.Stretch, - Width = 300, - Height = 150, - }); + var urlStringMin = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + ImageCropMode = ImageCropMode.Min, + Width = 300, + Height = 150, + }); + + var urlStringBoxPad = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + ImageCropMode = ImageCropMode.BoxPad, + Width = 300, + Height = 150, + }); + + var urlStringPad = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + ImageCropMode = ImageCropMode.Pad, + Width = 300, + Height = 150, + }); + + var urlStringMax = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + ImageCropMode = ImageCropMode.Max, + Width = 300, + Height = 150, + }); + + var urlStringStretch = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + ImageCropMode = ImageCropMode.Stretch, + Width = 300, + Height = 150, + }); Assert.AreEqual(MediaPath + "?rmode=min&width=300&height=150", urlStringMin); Assert.AreEqual(MediaPath + "?rmode=boxpad&width=300&height=150", urlStringBoxPad); @@ -184,76 +199,136 @@ public void GetImageUrl_SpecifiedCropModeTest() } /// - /// Test for upload property type + /// Test for upload property type /// [Test] public void GetImageUrl_UploadTypeTest() { - var urlString = s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) { ImageCropMode = ImageCropMode.Crop, ImageCropAnchor = ImageCropAnchor.Center, Width = 100, Height = 270, }); + Assert.AreEqual(MediaPath + "?rmode=crop&ranchor=center&width=100&height=270", urlString); } /// - /// Test for preferFocalPoint when focal point is centered + /// Test for preferFocalPoint when focal point is centered /// [Test] public void GetImageUrl_PreferFocalPointCenter() { - var urlString = s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) { Width = 300, Height = 150 }); + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + Width = 300, + Height = 150, + }); + Assert.AreEqual(MediaPath + "?width=300&height=150", urlString); } /// - /// Test to check if crop ratio is ignored if useCropDimensions is true + /// Test to check if crop ratio is ignored if useCropDimensions is true /// [Test] public void GetImageUrl_PreDefinedCropNoCoordinatesWithWidthAndFocalPointIgnore() { - var urlString = - s_generator.GetImageUrl( - new ImageUrlGenerationOptions(MediaPath) { FocalPoint = s_focus2, Width = 270, Height = 161 }); + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + FocalPoint = _focus2, + Width = 270, + Height = 161, + }); + Assert.AreEqual(MediaPath + "?rxy=0.4275,0.41&width=270&height=161", urlString); } /// - /// Test to check result when only a width parameter is passed, effectivly a resize only + /// Test to check result when only a width parameter is passed, effectivly a resize only /// [Test] public void GetImageUrl_WidthOnlyParameter() { - var urlString = s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) { Width = 200 }); + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + Width = 200, + }); + Assert.AreEqual(MediaPath + "?width=200", urlString); } /// - /// Test to check result when only a height parameter is passed, effectivly a resize only + /// Test to check result when only a height parameter is passed, effectivly a resize only /// [Test] public void GetImageUrl_HeightOnlyParameter() { - var urlString = s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) { Height = 200 }); + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + { + Height = 200, + }); + Assert.AreEqual(MediaPath + "?height=200", urlString); } /// - /// Test to check result when using a background color with padding + /// Test to check result when using a background color with padding /// [Test] public void GetImageUrl_BackgroundColorParameter() { - var urlString = s_generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) + var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath) { ImageCropMode = ImageCropMode.Pad, Width = 400, Height = 400, FurtherOptions = "&bgcolor=fff", }); + Assert.AreEqual(MediaPath + "?rmode=pad&width=400&height=400&bgcolor=fff", urlString); } + + /// + /// Test to check result when using a HMAC security key. + /// + [Test] + public void GetImageUrl_HMACSecurityKey() + { + var requestAuthorizationUtilities = new ImageSharpRequestAuthorizationUtilities( + Options.Create(new ImageSharpMiddlewareOptions() + { + HMACSecretKey = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31 } + }), + new QueryCollectionRequestParser(), + new[] + { + new ResizeWebProcessor() + }, + new CommandParser(Enumerable.Empty()), + new ServiceCollection().BuildServiceProvider()); + + var generator = new ImageSharpImageUrlGenerator(new string[0], requestAuthorizationUtilities); + var options = new ImageUrlGenerationOptions(MediaPath) + { + Width = 400, + Height = 400, + }; + + Assert.AreEqual(MediaPath + "?width=400&height=400&hmac=6335195986da0663e23eaadfb9bb32d537375aaeec253aae66b8f4388506b4b2", generator.GetImageUrl(options)); + + // CacheBusterValue isn't included in HMAC generation + options.CacheBusterValue = "not-included-in-hmac"; + Assert.AreEqual(MediaPath + "?width=400&height=400&v=not-included-in-hmac&hmac=6335195986da0663e23eaadfb9bb32d537375aaeec253aae66b8f4388506b4b2", generator.GetImageUrl(options)); + + // Removing height should generate a different HMAC + options.Height = null; + Assert.AreEqual(MediaPath + "?width=400&v=not-included-in-hmac&hmac=5bd24a05de5ea068533579863773ddac9269482ad515575be4aace7e9e50c88c", generator.GetImageUrl(options)); + + // But adding it again using FurtherOptions should include it (and produce the same HMAC as before) + options.FurtherOptions = "height=400"; + Assert.AreEqual(MediaPath + "?width=400&height=400&v=not-included-in-hmac&hmac=6335195986da0663e23eaadfb9bb32d537375aaeec253aae66b8f4388506b4b2", generator.GetImageUrl(options)); + } }