v12: Add HMAC image processing protection (#14181)

* Update to ImageSharp 2.1.0 and ImageSharp.Web 2.0.0-alpha.0.23

* Rename CachedNameLength to CacheHashLength and add CacheFolderDepth setting

* Replace PhysicalFileSystemProvider with WebRootImageProvider

* Support EXIF-orientation in image dimention extractor

* Remove virtual methods on FileProviderImageProvider

* Simplify FileInfoImageResolver

* Update to SixLabors.ImageSharp.Web 2.0.0-alpha.0.25 and remove custom providers

* Make CropWebProcessor EXIF orientation-aware

* Improve width/height sanitization

* Also use 'v' as cache buster value

* Add WebP to supported image file types

* Update to SixLabors.ImageSharp.Web 2.0.0-alpha.0.27 and fix test

* Fix rounding error and add test cases

* Update to newest and stable releases

* Move ImageSharpImageUrlGenerator to Umbraco.Web.Common

* Use IConfigureOptions to configure ImageSharp options

* Implement IEquatable on ImageUrlGenerationOptions classes

* Fix empty/null values in image URL generation and corresponding tests

* Use IsSupportedImageFormat extension method

* Remove unneeded reflection

* Add HMACSecretKey setting and add token when generating image URLs

* Ensure backoffice image URLs are generated by the server (and include a correct HMAC token)

* Abstract HMAC generation to IImageUrlTokenGenerator

* Change cache buster value to 'v' and use hexadecimal timestamp

* Update comments

* Fix backoffice thumbnail URL generation

* Update grid media thumbnail URL generation

* Remove breaking changes

* Strip unknown commands from image URL token

* Remove HMAC whitelisting possibility (not supported by ImageSharp)

* Update to SixLabors.ImageSharp 2.1.3

* Add comment to internal constructor

* Fix to support absolute image URLs

* Update to SixLabors.ImageSharp.Web 2.0.3-alpha.0.3

* Remove IImageUrlTokenGenerator and use ImageSharpRequestAuthorizationUtilities

* Move NuGet feed to config file

* Update to ImageSharp v3
This commit is contained in:
Ronald Barendse
2023-05-11 11:01:03 +02:00
committed by GitHub
parent f4ee0d027a
commit 27ae8bdba9
12 changed files with 329 additions and 216 deletions

View File

@@ -10,7 +10,7 @@ using Umbraco.Cms.Core.Configuration.Models;
namespace Umbraco.Cms.Imaging.ImageSharp;
/// <summary>
/// Configures the ImageSharp middleware options.
/// Configures the ImageSharp middleware options.
/// </summary>
/// <seealso cref="IConfigureOptions{ImageSharpMiddlewareOptions}" />
public sealed class ConfigureImageSharpMiddlewareOptions : IConfigureOptions<ImageSharpMiddlewareOptions>
@@ -19,7 +19,7 @@ public sealed class ConfigureImageSharpMiddlewareOptions : IConfigureOptions<Ima
private readonly ImagingSettings _imagingSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureImageSharpMiddlewareOptions" /> class.
/// Initializes a new instance of the <see cref="ConfigureImageSharpMiddlewareOptions" /> class.
/// </summary>
/// <param name="configuration">The ImageSharp configuration.</param>
/// <param name="imagingSettings">The Umbraco imaging settings.</param>
@@ -34,6 +34,7 @@ public sealed class ConfigureImageSharpMiddlewareOptions : IConfigureOptions<Ima
{
options.Configuration = _configuration;
options.HMACSecretKey = _imagingSettings.HMACSecretKey;
options.BrowserMaxAge = _imagingSettings.Cache.BrowserMaxAge;
options.CacheMaxAge = _imagingSettings.Cache.CacheMaxAge;
options.CacheHashLength = _imagingSettings.Cache.CacheHashLength;
@@ -41,22 +42,19 @@ public sealed class ConfigureImageSharpMiddlewareOptions : IConfigureOptions<Ima
// 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<int>(
context.Commands.GetValueOrDefault(ResizeWebProcessor.Width),
context.Culture);
int width = context.Parser.ParseValue<int>(context.Commands.GetValueOrDefault(ResizeWebProcessor.Width), context.Culture);
if (width <= 0 || width > _imagingSettings.Resize.MaxWidth)
{
context.Commands.Remove(ResizeWebProcessor.Width);
}
var height = context.Parser.ParseValue<int>(
context.Commands.GetValueOrDefault(ResizeWebProcessor.Height),
context.Culture);
int height = context.Parser.ParseValue<int>(context.Commands.GetValueOrDefault(ResizeWebProcessor.Height), context.Culture);
if (height <= 0 || height > _imagingSettings.Resize.MaxHeight)
{
context.Commands.Remove(ResizeWebProcessor.Height);
@@ -72,11 +70,16 @@ public sealed class ConfigureImageSharpMiddlewareOptions : IConfigureOptions<Ima
{
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;
}

View File

@@ -1,7 +1,9 @@
using System.Globalization;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using SixLabors.ImageSharp.Web.Processors;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Imaging.ImageSharp.ImageProcessors;
@@ -10,31 +12,47 @@ using static Umbraco.Cms.Core.Models.ImageUrlGenerationOptions;
namespace Umbraco.Cms.Imaging.ImageSharp.Media;
/// <summary>
/// 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.
/// </summary>
/// <seealso cref="IImageUrlGenerator" />
public sealed class ImageSharpImageUrlGenerator : IImageUrlGenerator
{
/// <inheritdoc />
public IEnumerable<string> SupportedImageFileTypes { get; }
private readonly RequestAuthorizationUtilities? _requestAuthorizationUtilities;
/// <summary>
/// Initializes a new instance of the <see cref="ImageSharpImageUrlGenerator" /> class.
/// Initializes a new instance of the <see cref="ImageSharpImageUrlGenerator" /> class.
/// </summary>
/// <param name="configuration">The ImageSharp configuration.</param>
public ImageSharpImageUrlGenerator(Configuration configuration)
: this(configuration.ImageFormats.SelectMany(f => f.FileExtensions).ToArray())
/// <param name="requestAuthorizationUtilities">Contains helpers that allow authorization of image requests.</param>
public ImageSharpImageUrlGenerator(Configuration configuration, RequestAuthorizationUtilities? requestAuthorizationUtilities)
: this(configuration.ImageFormats.SelectMany(f => f.FileExtensions).ToArray(), requestAuthorizationUtilities)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="ImageSharpImageUrlGenerator" /> class.
/// Initializes a new instance of the <see cref="ImageSharpImageUrlGenerator" /> class.
/// </summary>
/// <param name="configuration">The ImageSharp configuration.</param>
[Obsolete("Use ctor with all params - This will be removed in Umbraco 13.")]
public ImageSharpImageUrlGenerator(Configuration configuration)
: this(configuration, StaticServiceProvider.Instance.GetService<RequestAuthorizationUtilities>())
{ }
/// <summary>
/// Initializes a new instance of the <see cref="ImageSharpImageUrlGenerator" /> class.
/// </summary>
/// <param name="supportedImageFileTypes">The supported image file types/extensions.</param>
/// <param name="requestAuthorizationUtilities">Contains helpers that allow authorization of image requests.</param>
/// <remarks>
/// This constructor is only used for testing.
/// This constructor is only used for testing.
/// </remarks>
internal ImageSharpImageUrlGenerator(IEnumerable<string> supportedImageFileTypes) =>
internal ImageSharpImageUrlGenerator(IEnumerable<string> supportedImageFileTypes, RequestAuthorizationUtilities? requestAuthorizationUtilities = null)
{
SupportedImageFileTypes = supportedImageFileTypes;
_requestAuthorizationUtilities = requestAuthorizationUtilities;
}
/// <inheritdoc />
public IEnumerable<string> SupportedImageFileTypes { get; }
/// <inheritdoc />
public string? GetImageUrl(ImageUrlGenerationOptions? options)
@@ -47,47 +65,44 @@ public sealed class ImageSharpImageUrlGenerator : IImageUrlGenerator
var queryString = new Dictionary<string, string?>();
Dictionary<string, StringValues> 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<string, StringValues> kvp in furtherOptions)
@@ -95,9 +110,18 @@ public sealed class ImageSharpImageUrlGenerator : IImageUrlGenerator
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("rnd", options.CacheBusterValue);
queryString.Add("v", cacheBusterValue);
}
if (_requestAuthorizationUtilities is not null)
{
var uri = QueryHelpers.AddQueryString(options.ImageUrl, queryString);
if (_requestAuthorizationUtilities.ComputeHMAC(uri, CommandHandling.Sanitize) is string token && !string.IsNullOrEmpty(token))
{
queryString.Add(RequestAuthorizationUtilities.TokenCommand, token);
}
}
return QueryHelpers.AddQueryString(options.ImageUrl, queryString);

View File

@@ -6,7 +6,7 @@ using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Typed configuration options for image resize settings.
/// Typed configuration options for image resize settings.
/// </summary>
public class ImagingResizeSettings
{
@@ -14,13 +14,13 @@ public class ImagingResizeSettings
internal const int StaticMaxHeight = 5000;
/// <summary>
/// Gets or sets a value for the maximim resize width.
/// Gets or sets a value for the maximum resize width.
/// </summary>
[DefaultValue(StaticMaxWidth)]
public int MaxWidth { get; set; } = StaticMaxWidth;
/// <summary>
/// Gets or sets a value for the maximim resize height.
/// Gets or sets a value for the maximum resize height.
/// </summary>
[DefaultValue(StaticMaxHeight)]
public int MaxHeight { get; set; } = StaticMaxHeight;

View File

@@ -4,18 +4,27 @@
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Typed configuration options for imaging settings.
/// Typed configuration options for imaging settings.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigImaging)]
public class ImagingSettings
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Setting or updating this value will cause all existing generated URLs to become invalid and return a 400 Bad Request response code.
/// When set, the maximum resize settings are not used/validated anymore, because you can only request URLs with a valid HMAC token anyway.
/// </remarks>
public byte[] HMACSecretKey { get; set; } = Array.Empty<byte>();
/// <summary>
/// Gets or sets a value for imaging cache settings.
/// </summary>
public ImagingCacheSettings Cache { get; set; } = new();
/// <summary>
/// Gets or sets a value for imaging resize settings.
/// Gets or sets a value for imaging resize settings.
/// </summary>
public ImagingResizeSettings Resize { get; set; } = new();
}

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
@@ -14,24 +15,24 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Web.BackOffice.Controllers;
/// <summary>
/// A controller used to return images for media
/// A controller used to return images for media.
/// </summary>
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)]
public class ImagesController : UmbracoAuthorizedApiController
{
private readonly IImageUrlGenerator _imageUrlGenerator;
private readonly MediaFileManager _mediaFileManager;
private readonly IImageUrlGenerator _imageUrlGenerator;
private ContentSettings _contentSettings;
[Obsolete("Use non obsolete-constructor. Scheduled for removal in Umbraco 13.")]
public ImagesController(
MediaFileManager mediaFileManager,
IImageUrlGenerator imageUrlGenerator)
: this(mediaFileManager,
imageUrlGenerator,
StaticServiceProvider.Instance.GetRequiredService<IOptionsMonitor<ContentSettings>>())
: this(
mediaFileManager,
imageUrlGenerator,
StaticServiceProvider.Instance.GetRequiredService<IOptionsMonitor<ContentSettings>>())
{
}
[ActivatorUtilitiesConstructor]
@@ -45,30 +46,29 @@ public class ImagesController : UmbracoAuthorizedApiController
_contentSettings = contentSettingsMonitor.CurrentValue;
contentSettingsMonitor.OnChange(x => _contentSettings = x);
}
/// <summary>
/// Gets the big thumbnail image for the original image path
/// Gets the big thumbnail image for the original image path.
/// </summary>
/// <param name="originalImagePath"></param>
/// <returns></returns>
/// <remarks>
/// 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.
/// </remarks>
public IActionResult GetBigThumbnail(string originalImagePath) =>
string.IsNullOrWhiteSpace(originalImagePath)
? Ok()
: GetResized(originalImagePath, 500);
public IActionResult GetBigThumbnail(string originalImagePath)
=> string.IsNullOrWhiteSpace(originalImagePath)
? Ok()
: GetResized(originalImagePath, 500);
/// <summary>
/// Gets a resized image for the image at the given path
/// Gets a resized image for the image at the given path.
/// </summary>
/// <param name="imagePath"></param>
/// <param name="width"></param>
/// <returns></returns>
/// <remarks>
/// 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.
/// </remarks>
public IActionResult GetResized(string imagePath, int width)
{
@@ -76,7 +76,6 @@ public class ImagesController : UmbracoAuthorizedApiController
// 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
@@ -91,13 +90,13 @@ public class ImagesController : UmbracoAuthorizedApiController
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
@@ -105,12 +104,12 @@ public class ImagesController : UmbracoAuthorizedApiController
// 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 (imageUrl is not null)
@@ -142,7 +141,7 @@ public class ImagesController : UmbracoAuthorizedApiController
}
/// <summary>
/// Gets a processed image for the image at the given path
/// Gets a processed image for the image at the given path
/// </summary>
/// <param name="imagePath"></param>
/// <param name="width"></param>
@@ -150,14 +149,9 @@ public class ImagesController : UmbracoAuthorizedApiController
/// <param name="focalPointLeft"></param>
/// <param name="focalPointTop"></param>
/// <param name="mode"></param>
/// <param name="cacheBusterValue"></param>
/// <param name="cropX1"></param>
/// <param name="cropX2"></param>
/// <param name="cropY1"></param>
/// <param name="cropY2"></param>
/// <returns></returns>
/// <remarks>
/// 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.
/// </remarks>
public string? GetProcessedImageUrl(
string imagePath,
@@ -166,7 +160,7 @@ public class ImagesController : UmbracoAuthorizedApiController
decimal? focalPointLeft = null,
decimal? focalPointTop = null,
ImageCropMode mode = ImageCropMode.Max,
string cacheBusterValue = "",
string? cacheBusterValue = null,
decimal? cropX1 = null,
decimal? cropX2 = null,
decimal? cropY1 = null,
@@ -182,13 +176,11 @@ public class ImagesController : UmbracoAuthorizedApiController
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);

View File

@@ -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,

View File

@@ -313,7 +313,7 @@ function mediaHelper(umbRequestHelper, $http, $log) {
var thumbnailUrl = umbRequestHelper.getApiUrl(
"imagesApiBaseUrl",
"GetBigThumbnail",
[{ originalImagePath: imagePath }]) + '&rnd=' + Math.random();
[{ originalImagePath: imagePath }]);
return thumbnailUrl;
},

View File

@@ -14,7 +14,7 @@
}
});
function BlockCardController($scope, umbRequestHelper) {
function BlockCardController($scope, umbRequestHelper, mediaHelper) {
const vm = this;
vm.styleBackgroundImage = "none";
@@ -49,8 +49,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}')`;
};

View File

@@ -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;
}
});

View File

@@ -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;
var options = {
width: 800
};
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;
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();
}
});

View File

@@ -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;
}

View File

@@ -1,7 +1,14 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
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.Imaging.ImageSharp.Media;
@@ -14,19 +21,22 @@ 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 _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]);
private static readonly ImageUrlGenerationOptions.CropCoordinates _sCrop = new(0.58729977382575338m, 0.055768992440203169m, 0m, 0.32457553600198386m);
private static readonly ImageUrlGenerationOptions.FocalPointPosition _sFocus = new(0.96m, 0.80827067669172936m);
private static readonly ImageSharpImageUrlGenerator _sGenerator = new(Array.Empty<string>());
/// <summary>
/// Tests that the media path is returned if no options are provided.
/// </summary>
[Test]
public void GivenMediaPath_AndNoOptions_ReturnsMediaPath()
{
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath));
Assert.AreEqual(MediaPath, actual);
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);
}
/// <summary>
@@ -35,8 +45,14 @@ public class ImageSharpImageUrlGeneratorTests
[Test]
public void GivenNullOptions_ReturnsNull()
{
var actual = _sGenerator.GetImageUrl(null);
Assert.IsNull(actual);
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);
}
/// <summary>
@@ -45,14 +61,34 @@ public class ImageSharpImageUrlGeneratorTests
[Test]
public void GivenNullImageUrl_ReturnsNull()
{
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(null));
Assert.IsNull(actual);
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 = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
{
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);
}
[Test]
public void GetImageUrlFurtherOptionsModeAndQualityTest()
{
var urlString = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
{
Quality = 10,
FurtherOptions = "format=webp",
@@ -66,7 +102,7 @@ public class ImageSharpImageUrlGeneratorTests
[Test]
public void GetImageUrlFurtherOptionsWithModeAndQualityTest()
{
var urlString = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
{
FurtherOptions = "quality=10&format=webp",
});
@@ -77,62 +113,101 @@ public class ImageSharpImageUrlGeneratorTests
}
/// <summary>
/// Test that if an empty string image url is given, null is returned.
/// Test that if options is null, the generated image URL is also null.
/// </summary>
[Test]
public void GivenEmptyStringImageUrl_ReturnsEmptyString()
{
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty));
Assert.AreEqual(actual, string.Empty);
var urlString = _generator.GetImageUrl(null);
Assert.AreEqual(null, urlString);
}
/// <summary>
/// Tests the correct query string is returned when given a crop.
/// Test that if the image URL is null, the generated image URL is also null.
/// </summary>
[Test]
public void GivenCrop_ReturnsExpectedQueryString()
{
const string expected = "?cc=0.58729977382575338,0.055768992440203169,0,0.32457553600198386";
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty) { Crop = _sCrop });
Assert.AreEqual(expected, actual);
var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(null));
Assert.AreEqual(null, urlString);
}
/// <summary>
/// Tests the correct query string is returned when given a width.
/// Test that if the image URL is empty, the generated image URL is empty.
/// </summary>
[Test]
public void GivenWidth_ReturnsExpectedQueryString()
{
const string expected = "?width=200";
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty) { Width = 200 });
Assert.AreEqual(expected, actual);
var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty));
Assert.AreEqual(string.Empty, urlString);
}
/// <summary>
/// Tests the correct query string is returned when given a height.
/// Test the GetImageUrl method on the ImageCropDataSet Model
/// </summary>
[Test]
public void GivenHeight_ReturnsExpectedQueryString()
{
const string expected = "?height=200";
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty) { Height = 200 });
Assert.AreEqual(expected, actual);
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);
}
/// <summary>
/// Tests the correct query string is returned when provided a focal point.
/// Test that if Crop mode is specified as anything other than Crop the image doesn't use the crop
/// </summary>
[Test]
public void GivenFocalPoint_ReturnsExpectedQueryString()
{
const string expected = "?rxy=0.96,0.80827067669172936";
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty) { FocalPoint = _sFocus });
Assert.AreEqual(expected, actual);
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);
Assert.AreEqual(MediaPath + "?rmode=pad&width=300&height=150", urlStringPad);
Assert.AreEqual(MediaPath + "?rmode=max&width=300&height=150", urlStringMax);
Assert.AreEqual(MediaPath + "?rmode=stretch&width=300&height=150", urlStringStretch);
}
/// <summary>
/// Tests the correct query string is returned when given further options.
/// There are a few edge case inputs here to ensure thorough testing in future versions.
/// Test for upload property type
/// </summary>
[TestCase("&filter=comic&roundedcorners=radius-26%7Cbgcolor-fff", "?filter=comic&roundedcorners=radius-26%7Cbgcolor-fff")]
[TestCase("testoptions", "?testoptions=")]
@@ -140,100 +215,84 @@ public class ImageSharpImageUrlGeneratorTests
[TestCase("should=encode&$^%()", "?should=encode&$%5E%25()=")]
public void GivenFurtherOptions_ReturnsExpectedQueryString(string input, string expected)
{
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty)
var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
{
FurtherOptions = input,
});
Assert.AreEqual(expected, actual);
Assert.AreEqual(MediaPath + expected, urlString);
}
/// <summary>
/// Test that the correct query string is returned for all image crop modes.
/// Test for preferFocalPoint when focal point is centered
/// </summary>
[TestCase(ImageCropMode.Min, "?rmode=min")]
[TestCase(ImageCropMode.BoxPad, "?rmode=boxpad")]
[TestCase(ImageCropMode.Pad, "?rmode=pad")]
[TestCase(ImageCropMode.Max, "?rmode=max")]
[TestCase(ImageCropMode.Stretch, "?rmode=stretch")]
public void GivenCropMode_ReturnsExpectedQueryString(ImageCropMode cropMode, string expectedQueryString)
[Test]
public void GetImageUrl_PreferFocalPointCenter()
{
var cropUrl = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty)
var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
{
ImageCropMode = cropMode,
Width = 300,
Height = 150,
});
Assert.AreEqual(expectedQueryString, cropUrl);
Assert.AreEqual(MediaPath + "?width=300&height=150", urlString);
}
/// <summary>
/// Test that the correct query string is returned for all image crop anchors.
/// Test to check if crop ratio is ignored if useCropDimensions is true
/// </summary>
[TestCase(ImageCropAnchor.Bottom, "?ranchor=bottom")]
[TestCase(ImageCropAnchor.BottomLeft, "?ranchor=bottomleft")]
[TestCase(ImageCropAnchor.BottomRight, "?ranchor=bottomright")]
[TestCase(ImageCropAnchor.Center, "?ranchor=center")]
[TestCase(ImageCropAnchor.Left, "?ranchor=left")]
[TestCase(ImageCropAnchor.Right, "?ranchor=right")]
[TestCase(ImageCropAnchor.Top, "?ranchor=top")]
[TestCase(ImageCropAnchor.TopLeft, "?ranchor=topleft")]
[TestCase(ImageCropAnchor.TopRight, "?ranchor=topright")]
public void GivenCropAnchor_ReturnsExpectedQueryString(ImageCropAnchor imageCropAnchor, string expectedQueryString)
[Test]
public void GetImageUrl_PreDefinedCropNoCoordinatesWithWidthAndFocalPointIgnore()
{
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty)
var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
{
ImageCropAnchor = imageCropAnchor,
FocalPoint = _focus2,
Width = 270,
Height = 161,
});
Assert.AreEqual(expectedQueryString, actual);
Assert.AreEqual(MediaPath + "?rxy=0.4275,0.41&width=270&height=161", urlString);
}
/// <summary>
/// Tests that the quality query string always returns the input number regardless of value.
/// Test to check result when only a width parameter is passed, effectivly a resize only
/// </summary>
[TestCase(int.MinValue)]
[TestCase(-50)]
[TestCase(0)]
[TestCase(50)]
[TestCase(int.MaxValue)]
public void GivenQuality_ReturnsExpectedQueryString(int quality)
[Test]
public void GetImageUrl_WidthOnlyParameter()
{
var expected = "?quality=" + quality;
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty)
var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
{
Quality = quality,
Width = 200,
});
Assert.AreEqual(expected, actual);
Assert.AreEqual(MediaPath + "?width=200", urlString);
}
/// <summary>
/// Tests that the correct query string is returned for cache buster.
/// There are some edge case tests here to ensure thorough testing in future versions.
/// Test to check result when only a height parameter is passed, effectivly a resize only
/// </summary>
[TestCase("test-buster", "?rnd=test-buster")]
[TestCase("test-buster&&^-value", "?rnd=test-buster%26%26%5E-value")]
public void GivenCacheBusterValue_ReturnsExpectedQueryString(string input, string expected)
[Test]
public void GetImageUrl_HeightOnlyParameter()
{
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(string.Empty)
var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
{
CacheBusterValue = input,
Height = 200,
});
Assert.AreEqual(expected, actual);
Assert.AreEqual(MediaPath + "?height=200", urlString);
}
/// <summary>
/// Tests that an expected query string is returned when all options are given.
/// This will be a good test to see if something breaks with ordering of query string parameters.
/// Test to check result when using a background color with padding
/// </summary>
[Test]
public void GivenAllOptions_ReturnsExpectedQueryString()
{
const string expected =
"/media/1005/img_0671.jpg?cc=0.58729977382575338,0.055768992440203169,0,0.32457553600198386&rxy=0.96,0.80827067669172936&rmode=stretch&ranchor=right&width=200&height=200&quality=50&more=options&rnd=buster";
var actual = _sGenerator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
var urlString = _generator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
{
Quality = 50,
Crop = _sCrop,
FocalPoint = _sFocus,
Crop = _crop,
FocalPoint = _focus1,
CacheBusterValue = "buster",
FurtherOptions = "more=options",
Height = 200,
@@ -242,6 +301,47 @@ public class ImageSharpImageUrlGeneratorTests
ImageCropMode = ImageCropMode.Stretch,
});
Assert.AreEqual(expected, actual);
Assert.AreEqual(MediaPath + "?cc=0.58729977382575338,0.055768992440203169,0,0.32457553600198386&rxy=0.96,0.80827067669172936&rmode=stretch&ranchor=right&width=200&height=200&quality=50&more=options&v=buster", urlString);
}
/// <summary>
/// Test to check result when using a HMAC security key.
/// </summary>
[Test]
public void GetImageUrl_HMACSecurityKey()
{
var requestAuthorizationUtilities = new RequestAuthorizationUtilities(
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<ICommandConverter>()),
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));
}
}