Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
102a422aac | ||
|
|
c8c9026f12 | ||
|
|
b5015bd14f |
@@ -0,0 +1,174 @@
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using SixLabors.ImageSharp.Web;
|
||||
using SixLabors.ImageSharp.Web.Commands;
|
||||
using SixLabors.ImageSharp.Web.Middleware;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
|
||||
namespace Umbraco.Cms.Imaging.ImageSharp.Media;
|
||||
|
||||
/// <summary>
|
||||
/// ImageSharp-backed <see cref="IImageUrlTokenGenerator"/> that re-signs image URLs using the
|
||||
/// configured HMAC secret key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Computed tokens are cached in-process keyed by the canonical, sanitised URL (path + only the
|
||||
/// query parameters that contribute to the HMAC). Cache-buster parameters such as <c>v</c> or
|
||||
/// <c>rnd</c> are stripped from the cache key so they do not bloat memory when used per-request.
|
||||
/// The cache is bounded with an entry cap and sliding expiration so stale entries (e.g. for
|
||||
/// deleted media or unused crop variants) cannot accumulate indefinitely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The secret key is captured at construction via <see cref="IOptions{TOptions}"/>, mirroring
|
||||
/// ImageSharp.Web's own <c>RequestAuthorizationUtilities</c>. Changing the key at runtime
|
||||
/// requires an application restart.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ImageSharpImageUrlTokenGenerator : IImageUrlTokenGenerator, IDisposable
|
||||
{
|
||||
private readonly RequestAuthorizationUtilities? _requestAuthorizationUtilities;
|
||||
private readonly ImageSharpMiddlewareOptions _options;
|
||||
private readonly MemoryCache _tokenCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ImageSharpImageUrlTokenGenerator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="requestAuthorizationUtilities">The ImageSharp request authorization utilities used to compute HMAC tokens.</param>
|
||||
/// <param name="options">The ImageSharp middleware options containing the HMAC secret key.</param>
|
||||
public ImageSharpImageUrlTokenGenerator(
|
||||
RequestAuthorizationUtilities? requestAuthorizationUtilities,
|
||||
IOptions<ImageSharpMiddlewareOptions> options)
|
||||
{
|
||||
_requestAuthorizationUtilities = requestAuthorizationUtilities;
|
||||
_options = options.Value;
|
||||
|
||||
// 10k entries × ~500 bytes per CacheEntry (canonical URL ~250 chars, 64-char token,
|
||||
// dictionary overhead) caps the in-process token cache around 5 MB.
|
||||
_tokenCache = new MemoryCache(new MemoryCacheOptions
|
||||
{
|
||||
SizeLimit = 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string RefreshSignature(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url) || _requestAuthorizationUtilities is null || _options.HMACSecretKey.Length == 0)
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
SplitUrl split = SplitAndDecodeQuery(url);
|
||||
|
||||
Dictionary<string, StringValues> outputParams = split.DecodedQuery.Length == 0
|
||||
? []
|
||||
: QueryHelpers.ParseQuery(split.DecodedQuery);
|
||||
|
||||
// Strip any previously persisted HMAC token; we are about to compute a fresh one.
|
||||
outputParams.Remove(RequestAuthorizationUtilities.TokenCommand);
|
||||
|
||||
// Build a sanitised command collection - only parameters recognised by an
|
||||
// IImageWebProcessor - and use that as the cache key. This collapses different cache-buster
|
||||
// values (which don't affect the HMAC) onto the same entry.
|
||||
var canonicalCommands = new CommandCollection();
|
||||
foreach (KeyValuePair<string, StringValues> kvp in outputParams)
|
||||
{
|
||||
canonicalCommands.Add(kvp.Key, kvp.Value.ToString());
|
||||
}
|
||||
|
||||
_requestAuthorizationUtilities.StripUnknownCommands(canonicalCommands);
|
||||
|
||||
var canonicalUrl = BuildUrl(split.Path, canonicalCommands);
|
||||
|
||||
// Sign over the already-sanitised URL; no need for ImageSharp.Web to sanitise again.
|
||||
var token = _tokenCache.GetOrCreate(canonicalUrl, entry =>
|
||||
{
|
||||
entry.Size = 1;
|
||||
entry.SlidingExpiration = TimeSpan.FromHours(24);
|
||||
return ComputeToken(canonicalUrl);
|
||||
}) ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(token) is false)
|
||||
{
|
||||
outputParams[RequestAuthorizationUtilities.TokenCommand] = token;
|
||||
}
|
||||
|
||||
return BuildOutputUrl(split.Path, outputParams, split.HadEntityEncoding);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _tokenCache.Dispose();
|
||||
|
||||
/// <summary>
|
||||
/// Splits a URL into its path and a parsable query, normalising HTML-entity-encoded
|
||||
/// ampersands in the query portion.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rich text editors store URLs with <c>&amp;</c> as the query separator so the
|
||||
/// surrounding markup is valid HTML. The query must be decoded before it can be parsed
|
||||
/// as a standard query string; the returned <see cref="SplitUrl.HadEntityEncoding"/>
|
||||
/// flag tells the caller to re-encode the output the same way. The path is returned
|
||||
/// unchanged.
|
||||
/// </remarks>
|
||||
private static SplitUrl SplitAndDecodeQuery(string url)
|
||||
{
|
||||
var questionIndex = url.IndexOf('?');
|
||||
var pathPart = questionIndex < 0 ? url : url[..questionIndex];
|
||||
var rawQuery = questionIndex < 0 ? string.Empty : url[(questionIndex + 1)..];
|
||||
var hadEntityEncoding = rawQuery.Contains("&", StringComparison.Ordinal);
|
||||
if (hadEntityEncoding)
|
||||
{
|
||||
rawQuery = rawQuery.Replace("&", "&");
|
||||
}
|
||||
|
||||
return new SplitUrl(pathPart, rawQuery, hadEntityEncoding);
|
||||
}
|
||||
|
||||
private string ComputeToken(string canonicalUrl)
|
||||
=> _requestAuthorizationUtilities!.ComputeHMAC(canonicalUrl, CommandHandling.None) ?? string.Empty;
|
||||
|
||||
private sealed record SplitUrl(string Path, string DecodedQuery, bool HadEntityEncoding);
|
||||
|
||||
private static string BuildUrl(string pathPart, CommandCollection commands)
|
||||
{
|
||||
if (commands.Keys.Any() is false)
|
||||
{
|
||||
return pathPart;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, string?>();
|
||||
foreach (var key in commands.Keys)
|
||||
{
|
||||
dict[key] = commands[key];
|
||||
}
|
||||
|
||||
return QueryHelpers.AddQueryString(pathPart, dict);
|
||||
}
|
||||
|
||||
private static string BuildOutputUrl(string pathPart, Dictionary<string, StringValues> queryParams, bool entityEncodeSeparators)
|
||||
{
|
||||
if (queryParams.Count == 0)
|
||||
{
|
||||
return pathPart;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, string?>(queryParams.Count);
|
||||
foreach (KeyValuePair<string, StringValues> kvp in queryParams)
|
||||
{
|
||||
dict[kvp.Key] = kvp.Value.ToString();
|
||||
}
|
||||
|
||||
var withQuery = QueryHelpers.AddQueryString(pathPart, dict);
|
||||
if (entityEncodeSeparators is false)
|
||||
{
|
||||
return withQuery;
|
||||
}
|
||||
|
||||
// Only encode the query portion - the path part is left untouched even if it contains '&'.
|
||||
var qStart = pathPart.Length + 1;
|
||||
return withQuery[..qStart] + withQuery[qStart..].Replace("&", "&");
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ public static class UmbracoBuilderExtensions
|
||||
|
||||
builder.Services.AddSingleton<IImageUrlGenerator, ImageSharpImageUrlGenerator>();
|
||||
|
||||
// Replaces the no-op IImageUrlTokenGenerator registered in Core; allows rich text render
|
||||
// paths to re-sign image URLs against the current HMACSecretKey after a key rotation.
|
||||
builder.Services.AddSingleton<IImageUrlTokenGenerator, ImageSharpImageUrlTokenGenerator>();
|
||||
|
||||
builder.Services.AddImageSharp()
|
||||
// Replace default image provider
|
||||
.ClearProviders()
|
||||
|
||||
@@ -24,6 +24,7 @@ using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Logging;
|
||||
using Umbraco.Cms.Core.Mail;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Packaging;
|
||||
@@ -220,6 +221,11 @@ namespace Umbraco.Cms.Core.DependencyInjection
|
||||
Services.AddSingleton<HtmlImageSourceParser>();
|
||||
Services.AddSingleton<HtmlUrlParser>();
|
||||
|
||||
// Default no-op signer. The ImageSharp 3+ package replaces this with a real implementation
|
||||
// that re-signs URLs using the current HMACSecretKey; ImageSharp 2 leaves the no-op in
|
||||
// place (it has no HMAC support).
|
||||
Services.AddSingleton<IImageUrlTokenGenerator, NoopImageUrlTokenGenerator>();
|
||||
|
||||
// register properties fallback
|
||||
Services.AddUnique<IPublishedValueFallback, PublishedValueFallback>();
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Umbraco.Cms.Core.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the HMAC signature on a generated image URL using the secret key
|
||||
/// that is currently configured on the imaging middleware.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rich text editors persist image URLs (with the HMAC token baked in) into stored markup.
|
||||
/// When the secret key is rotated, the persisted token no longer validates and the image
|
||||
/// fails to render. Render-time pipelines call <see cref="RefreshSignature"/> to strip any
|
||||
/// stale token from the URL and re-sign with the current key.
|
||||
/// </remarks>
|
||||
public interface IImageUrlTokenGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Strips any existing HMAC token from <paramref name="url"/> and returns the URL
|
||||
/// signed with the currently configured secret key. If no key is configured, or
|
||||
/// the input is empty, the URL is returned unchanged.
|
||||
/// </summary>
|
||||
/// <param name="url">The image URL, optionally containing a stale HMAC token in its query string.</param>
|
||||
/// <returns>The image URL with a freshly computed HMAC token (or unchanged if no key is configured).</returns>
|
||||
string RefreshSignature(string url);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Umbraco.Cms.Core.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IImageUrlTokenGenerator"/> implementation that returns URLs unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Registered as the fallback in Core so that consumers can inject <see cref="IImageUrlTokenGenerator"/>
|
||||
/// unconditionally. The ImageSharp 3+ package replaces this registration with a real signer; ImageSharp 2
|
||||
/// (which has no HMAC support) and any custom imaging package leave the no-op in place.
|
||||
/// </remarks>
|
||||
internal sealed class NoopImageUrlTokenGenerator : IImageUrlTokenGenerator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string RefreshSignature(string url) => url;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -7,21 +10,16 @@ namespace Umbraco.Cms.Core.Templates;
|
||||
/// <summary>
|
||||
/// Utility class used to parse and update image sources in HTML content based on Umbraco media references.
|
||||
/// </summary>
|
||||
public sealed class HtmlImageSourceParser
|
||||
public sealed partial class HtmlImageSourceParser
|
||||
{
|
||||
private static readonly Regex ResolveImgPattern = new(
|
||||
@"<img[^>]*(data-udi=""([^""]*)"")[^>]*>",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
|
||||
private static readonly Regex _resolveImgRegex = ResolveImgRegex();
|
||||
|
||||
private static readonly Regex SrcAttributeRegex = new(
|
||||
@"src=""([^""\?]*)(\?[^""]*)?""",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
|
||||
private static readonly Regex _srcAttributeRegex = SrcAttributeRegex();
|
||||
|
||||
private static readonly Regex DataUdiAttributeRegex = new(
|
||||
@"data-udi=\\?(?:""|')(?<udi>umb://[A-z0-9\-]+/[A-z0-9]+)\\?(?:""|')",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex _dataUdiAttributeRegex = DataUdiAttributeRegex();
|
||||
|
||||
private readonly IPublishedUrlProvider? _publishedUrlProvider;
|
||||
private readonly IImageUrlTokenGenerator _imageUrlTokenGenerator;
|
||||
|
||||
private Func<Guid, string?>? _getMediaUrl;
|
||||
|
||||
@@ -29,23 +27,53 @@ public sealed class HtmlImageSourceParser
|
||||
/// Initializes a new instance of the <see cref="HtmlImageSourceParser"/> class.
|
||||
/// </summary>
|
||||
/// <param name="getMediaUrl">A function that retrieves the media URL for a given GUID.</param>
|
||||
public HtmlImageSourceParser(Func<Guid, string> getMediaUrl) => _getMediaUrl = getMediaUrl;
|
||||
/// <param name="imageUrlTokenGenerator">Used to re-sign rendered image URLs against the current HMAC secret key.</param>
|
||||
public HtmlImageSourceParser(Func<Guid, string> getMediaUrl, IImageUrlTokenGenerator imageUrlTokenGenerator)
|
||||
{
|
||||
_getMediaUrl = getMediaUrl;
|
||||
_imageUrlTokenGenerator = imageUrlTokenGenerator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HtmlImageSourceParser"/> class.
|
||||
/// </summary>
|
||||
/// <param name="publishedUrlProvider">The published URL provider for resolving media URLs.</param>
|
||||
public HtmlImageSourceParser(IPublishedUrlProvider publishedUrlProvider) =>
|
||||
/// <param name="imageUrlTokenGenerator">Used to re-sign rendered image URLs against the current HMAC secret key.</param>
|
||||
public HtmlImageSourceParser(IPublishedUrlProvider publishedUrlProvider, IImageUrlTokenGenerator imageUrlTokenGenerator)
|
||||
{
|
||||
_publishedUrlProvider = publishedUrlProvider;
|
||||
_imageUrlTokenGenerator = imageUrlTokenGenerator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses out media UDIs from an html string based on 'data-udi' html attributes
|
||||
/// Initializes a new instance of the <see cref="HtmlImageSourceParser"/> class.
|
||||
/// </summary>
|
||||
/// <param name="text"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="getMediaUrl">A function that retrieves the media URL for a given GUID.</param>
|
||||
[Obsolete("Please use the constructor that accepts IImageUrlTokenGenerator. Scheduled for removal in Umbraco 19.")]
|
||||
public HtmlImageSourceParser(Func<Guid, string> getMediaUrl)
|
||||
: this(getMediaUrl, StaticServiceProvider.Instance.GetRequiredService<IImageUrlTokenGenerator>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HtmlImageSourceParser"/> class.
|
||||
/// </summary>
|
||||
/// <param name="publishedUrlProvider">The published URL provider for resolving media URLs.</param>
|
||||
[Obsolete("Please use the constructor that accepts IImageUrlTokenGenerator. Scheduled for removal in Umbraco 19.")]
|
||||
public HtmlImageSourceParser(IPublishedUrlProvider publishedUrlProvider)
|
||||
: this(publishedUrlProvider, StaticServiceProvider.Instance.GetRequiredService<IImageUrlTokenGenerator>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses media UDIs out of an HTML string by reading <c>data-udi</c> attributes on
|
||||
/// <c><a></c> and <c><img></c> tags.
|
||||
/// </summary>
|
||||
/// <param name="text">The HTML text to scan.</param>
|
||||
/// <returns>The parseable UDIs found on <c>data-udi</c> attributes, in document order.</returns>
|
||||
public IEnumerable<Udi> FindUdisFromDataAttributes(string text)
|
||||
{
|
||||
MatchCollection matches = DataUdiAttributeRegex.Matches(text);
|
||||
MatchCollection matches = _dataUdiAttributeRegex.Matches(text);
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
@@ -61,19 +89,21 @@ public sealed class HtmlImageSourceParser
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the string looking for Umbraco image tags and updates them to their up-to-date image sources.
|
||||
/// Refreshes the <c>src</c> attribute of every Umbraco <c><img></c> tag in the supplied HTML
|
||||
/// so the rendered URL points at the current media path and carries an up-to-date HMAC signature.
|
||||
/// </summary>
|
||||
/// <param name="text"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>Umbraco image tags are identified by their data-udi attributes</remarks>
|
||||
/// <param name="text">The HTML text to process.</param>
|
||||
/// <returns>
|
||||
/// The HTML with each <c><img data-udi="..."></c> rewritten: the path is replaced with the
|
||||
/// current media URL (preserving the persisted query string), and any HMAC token in the query
|
||||
/// is re-signed against the currently configured secret key. Other tags are returned unchanged.
|
||||
/// </returns>
|
||||
/// <remarks>Umbraco image tags are identified by their <c>data-udi</c> attributes.</remarks>
|
||||
public string EnsureImageSources(string text)
|
||||
{
|
||||
if (_getMediaUrl == null)
|
||||
{
|
||||
_getMediaUrl = guid => _publishedUrlProvider?.GetMediaUrl(guid);
|
||||
}
|
||||
_getMediaUrl ??= guid => _publishedUrlProvider?.GetMediaUrl(guid);
|
||||
|
||||
return ResolveImgPattern.Replace(text, match =>
|
||||
return _resolveImgRegex.Replace(text, match =>
|
||||
{
|
||||
// match groups:
|
||||
// - 1 = the data-udi attribute
|
||||
@@ -88,7 +118,7 @@ public sealed class HtmlImageSourceParser
|
||||
// src match groups:
|
||||
// - 1 = the src attribute value until the query string
|
||||
// - 2 = the src attribute query string including the '?'
|
||||
Match src = SrcAttributeRegex.Match(match.Value);
|
||||
Match src = _srcAttributeRegex.Match(match.Value);
|
||||
|
||||
if (src.Success == false)
|
||||
{
|
||||
@@ -104,29 +134,44 @@ public sealed class HtmlImageSourceParser
|
||||
return match.Value;
|
||||
}
|
||||
|
||||
var newImgTag = match.Value.Replace(src.Value, $"src=\"{mediaUrl}{src.Groups[2].Value}\"");
|
||||
// Re-sign the URL so a rotated HMAC secret key doesn't break previously-authored images.
|
||||
// No-op when HMAC isn't configured.
|
||||
var refreshedSrc = _imageUrlTokenGenerator.RefreshSignature($"{mediaUrl}{src.Groups[2].Value}");
|
||||
|
||||
return newImgTag;
|
||||
return match.Value.Replace(src.Value, $"src=\"{refreshedSrc}\"");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes media URLs from <img> tags where a data-udi attribute is present
|
||||
/// Clears the media path from the <c>src</c> attribute of every <c><img></c> tag that has a
|
||||
/// <c>data-udi</c> attribute, preserving any query string the URL carried.
|
||||
/// </summary>
|
||||
/// <param name="text"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="text">The HTML text to process.</param>
|
||||
/// <returns>
|
||||
/// The HTML with the path portion of each Umbraco-managed image <c>src</c> emptied (the query
|
||||
/// string, if present, is left in place). Tags without a <c>data-udi</c> attribute are unchanged.
|
||||
/// </returns>
|
||||
public string RemoveImageSources(string text)
|
||||
|
||||
// find each ResolveImgPattern match in the text, then find each
|
||||
// SrcAttributeRegex match in the match value, then replace the src
|
||||
// attribute value with an empty string
|
||||
// (see comment in ResolveMediaFromTextString for group reference)
|
||||
=> ResolveImgPattern.Replace(text, match =>
|
||||
=> _resolveImgRegex.Replace(text, match =>
|
||||
{
|
||||
// Find the src attribute
|
||||
Match src = SrcAttributeRegex.Match(match.Value);
|
||||
Match src = _srcAttributeRegex.Match(match.Value);
|
||||
|
||||
return src.Success == false || string.IsNullOrWhiteSpace(src.Groups[1].Value) ?
|
||||
match.Value : match.Value.Replace(src.Groups[1].Value, string.Empty);
|
||||
});
|
||||
|
||||
[GeneratedRegex(@"<img[^>]*(data-udi=""([^""]*)"")[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace, "en-GB")]
|
||||
private static partial Regex ResolveImgRegex();
|
||||
|
||||
[GeneratedRegex(@"src=""([^""\?]*)(\?[^""]*)?""", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace, "en-GB")]
|
||||
private static partial Regex SrcAttributeRegex();
|
||||
|
||||
[GeneratedRegex(@"data-udi=\\?(?:""|')(?<udi>umb://[A-z0-9\-]+/[A-z0-9]+)\\?(?:""|')", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex DataUdiAttributeRegex();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using HtmlAgilityPack;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Extensions;
|
||||
@@ -12,26 +12,30 @@ internal sealed class ApiRichTextMarkupParser : ApiRichTextParserBase, IApiRichT
|
||||
{
|
||||
private readonly IPublishedContentCache _publishedContentCache;
|
||||
private readonly IPublishedMediaCache _publishedMediaCache;
|
||||
private readonly IImageUrlTokenGenerator _imageUrlTokenGenerator;
|
||||
private readonly ILogger<ApiRichTextMarkupParser> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Umbraco.Cms.Infrastructure.DeliveryApi.ApiRichTextMarkupParser"/> class.
|
||||
/// Initializes a new instance of the <see cref="ApiRichTextMarkupParser"/> class.
|
||||
/// </summary>
|
||||
/// <param name="apiContentRouteBuilder">The <see cref="IApiContentRouteBuilder"/> used to build API content routes.</param>
|
||||
/// <param name="mediaUrlProvider">The <see cref="IApiMediaUrlProvider"/> used to provide media URLs for the API.</param>
|
||||
/// <param name="publishedContentCache">The <see cref="IPublishedContentCache"/> for accessing published content.</param>
|
||||
/// <param name="publishedMediaCache">The <see cref="IPublishedMediaCache"/> for accessing published media.</param>
|
||||
/// <param name="imageUrlTokenGenerator">Used to re-sign rendered image URLs against the current HMAC secret key.</param>
|
||||
/// <param name="logger">The <see cref="ILogger{ApiRichTextMarkupParser}"/> instance for logging.</param>
|
||||
public ApiRichTextMarkupParser(
|
||||
IApiContentRouteBuilder apiContentRouteBuilder,
|
||||
IApiMediaUrlProvider mediaUrlProvider,
|
||||
IPublishedContentCache publishedContentCache,
|
||||
IPublishedMediaCache publishedMediaCache,
|
||||
IImageUrlTokenGenerator imageUrlTokenGenerator,
|
||||
ILogger<ApiRichTextMarkupParser> logger)
|
||||
: base(apiContentRouteBuilder, mediaUrlProvider)
|
||||
{
|
||||
_publishedContentCache = publishedContentCache;
|
||||
_publishedMediaCache = publishedMediaCache;
|
||||
_imageUrlTokenGenerator = imageUrlTokenGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -120,7 +124,10 @@ internal sealed class ApiRichTextMarkupParser : ApiRichTextParserBase, IApiRichT
|
||||
? $"?{currentImageSource.Split('?').Last()}"
|
||||
: null;
|
||||
|
||||
image.SetAttributeValue("src", $"{mediaUrl}{currentImageQueryString}");
|
||||
// Re-sign the URL so a rotated HMAC secret key doesn't break previously-authored images.
|
||||
// No-op when HMAC isn't configured.
|
||||
var refreshedSrc = _imageUrlTokenGenerator.RefreshSignature($"{mediaUrl}{currentImageQueryString}");
|
||||
image.SetAttributeValue("src", refreshedSrc);
|
||||
image.Attributes.Remove("data-udi");
|
||||
|
||||
// we don't want the "data-caption" attribute, it's already part of the output as <figcaption>
|
||||
|
||||
@@ -3,6 +3,7 @@ using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.DeliveryApi;
|
||||
@@ -714,6 +715,7 @@ public class RichTextParserTests : PropertyValueConverterTests
|
||||
urlProvider,
|
||||
cacheManager.Content,
|
||||
cacheManager.Media,
|
||||
new NoopImageUrlTokenGenerator(),
|
||||
Mock.Of<ILogger<ApiRichTextMarkupParser>>());
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
@@ -162,7 +163,7 @@ internal class RichTextAllowedMediaTypeValidatorTests
|
||||
var mediaTypeServiceMock = new Mock<IMediaTypeService>();
|
||||
|
||||
var validator = new RichTextAllowedMediaTypeValidator(
|
||||
new HtmlImageSourceParser(_ => string.Empty),
|
||||
new HtmlImageSourceParser(_ => string.Empty, new NoopImageUrlTokenGenerator()),
|
||||
mediaServiceMock.Object,
|
||||
Mock.Of<ILocalizedTextService>(),
|
||||
new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()),
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Cms.Core.Services.Navigation;
|
||||
using Umbraco.Cms.Core.Templates;
|
||||
@@ -22,6 +21,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Templates;
|
||||
[TestFixture]
|
||||
public class HtmlImageSourceParserTests
|
||||
{
|
||||
private static IImageUrlTokenGenerator NoopSigner() => new NoopImageUrlTokenGenerator();
|
||||
|
||||
[Test]
|
||||
public void Returns_Udis_From_Data_Udi_Html_Attributes()
|
||||
{
|
||||
@@ -31,7 +32,7 @@ public class HtmlImageSourceParserTests
|
||||
</div>
|
||||
</p><p><img src='/media/234234.jpg' data-udi=""umb://media-type/B726D735E4C446D58F703F3FBCFC97A5"" /></p>";
|
||||
|
||||
var imageSourceParser = new HtmlImageSourceParser(Mock.Of<IPublishedUrlProvider>());
|
||||
var imageSourceParser = new HtmlImageSourceParser(Mock.Of<IPublishedUrlProvider>(), NoopSigner());
|
||||
|
||||
var result = imageSourceParser.FindUdisFromDataAttributes(input).ToList();
|
||||
Assert.AreEqual(2, result.Count);
|
||||
@@ -70,7 +71,7 @@ public class HtmlImageSourceParserTests
|
||||
[Category("Remove image sources")]
|
||||
public string Remove_Image_Sources(string sourceHtml)
|
||||
{
|
||||
var imageSourceParser = new HtmlImageSourceParser(Mock.Of<IPublishedUrlProvider>());
|
||||
var imageSourceParser = new HtmlImageSourceParser(Mock.Of<IPublishedUrlProvider>(), NoopSigner());
|
||||
|
||||
var actual = imageSourceParser.RemoveImageSources(sourceHtml);
|
||||
|
||||
@@ -120,7 +121,7 @@ public class HtmlImageSourceParserTests
|
||||
var mediaCache = Mock.Get(reference.UmbracoContext.Media);
|
||||
mediaCache.Setup(x => x.GetById(It.IsAny<Guid>())).Returns(media.Object);
|
||||
|
||||
var imageSourceParser = new HtmlImageSourceParser(publishedUrlProvider);
|
||||
var imageSourceParser = new HtmlImageSourceParser(publishedUrlProvider, NoopSigner());
|
||||
|
||||
var result = imageSourceParser.EnsureImageSources(@"<p>
|
||||
<div>
|
||||
@@ -197,7 +198,7 @@ public class HtmlImageSourceParserTests
|
||||
public string Ensure_ImageSources_Processing(string sourceHtml)
|
||||
{
|
||||
var fakeMediaUrl = "/media/1001/image.jpg";
|
||||
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl);
|
||||
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl, NoopSigner());
|
||||
var actual = parser.EnsureImageSources(sourceHtml);
|
||||
|
||||
return actual;
|
||||
@@ -214,7 +215,7 @@ public class HtmlImageSourceParserTests
|
||||
var text = $@"<img src=""{longText}"" />";
|
||||
|
||||
var fakeMediaUrl = "/media/1001/image.jpg";
|
||||
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl);
|
||||
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl, NoopSigner());
|
||||
|
||||
var timer = new Stopwatch();
|
||||
timer.Start();
|
||||
@@ -223,4 +224,44 @@ public class HtmlImageSourceParserTests
|
||||
|
||||
Assert.IsTrue(timer.ElapsedMilliseconds <= maxMsToRun);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EnsureImageSources_Calls_TokenGenerator_With_Refreshed_Url()
|
||||
{
|
||||
// Verifies the signer is invoked on the final URL (path from media provider + persisted query string)
|
||||
// and that its return value replaces the src attribute.
|
||||
var fakeMediaUrl = "/media/1001/image.jpg";
|
||||
var signerMock = new Mock<IImageUrlTokenGenerator>();
|
||||
signerMock
|
||||
.Setup(s => s.RefreshSignature(It.IsAny<string>()))
|
||||
.Returns<string>(url => url + "&hmac=fresh");
|
||||
|
||||
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl, signerMock.Object);
|
||||
|
||||
var input =
|
||||
@"<img src=""old/path.jpg?width=100&hmac=stale"" data-udi=""umb://media/81BB2036034F418BB61FC7160D68DCD4""/>";
|
||||
|
||||
var result = parser.EnsureImageSources(input);
|
||||
|
||||
signerMock.Verify(
|
||||
s => s.RefreshSignature("/media/1001/image.jpg?width=100&hmac=stale"),
|
||||
Times.Once);
|
||||
StringAssert.Contains("src=\"/media/1001/image.jpg?width=100&hmac=stale&hmac=fresh\"", result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EnsureImageSources_Noop_Signer_Leaves_Src_Unchanged()
|
||||
{
|
||||
var fakeMediaUrl = "/media/1001/image.jpg";
|
||||
var parser = new HtmlImageSourceParser(guid => fakeMediaUrl, new NoopImageUrlTokenGenerator());
|
||||
|
||||
var input =
|
||||
@"<img src=""x?width=100"" data-udi=""umb://media/81BB2036034F418BB61FC7160D68DCD4""/>";
|
||||
|
||||
var result = parser.EnsureImageSources(input);
|
||||
|
||||
Assert.AreEqual(
|
||||
@"<img src=""/media/1001/image.jpg?width=100"" data-udi=""umb://media/81BB2036034F418BB61FC7160D68DCD4""/>",
|
||||
result);
|
||||
}
|
||||
}
|
||||
|
||||
+42
@@ -3,6 +3,7 @@ using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
@@ -16,6 +17,7 @@ public class ApiRichTextMarkupParserTests
|
||||
{
|
||||
private Mock<IApiContentRouteBuilder> _apiContentRouteBuilder;
|
||||
private Mock<IApiMediaUrlProvider> _apiMediaUrlProvider;
|
||||
private Mock<IImageUrlTokenGenerator> _imageUrlTokenGenerator;
|
||||
|
||||
[Test]
|
||||
public void Can_Parse_Legacy_LocalLinks()
|
||||
@@ -178,6 +180,41 @@ public class ApiRichTextMarkupParserTests
|
||||
Assert.AreEqual(expectedOutput, parsedHtml);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LocalImages_Are_ReSigned_Via_TokenGenerator()
|
||||
{
|
||||
// Verifies the token generator is given the rebuilt src (provider URL + persisted query string)
|
||||
// and that its return value replaces the src attribute. The signer is responsible for handling
|
||||
// any HTML-entity encoding in the query string (RTE markup typically uses &).
|
||||
var key1 = Guid.Parse("395bdc0e8f4d4ad4af7f3a3f6265651e");
|
||||
var data1 = new MockData()
|
||||
.WithKey(key1)
|
||||
.WithMediaUrl("https://localhost:44331/media/bdofwokn/77gtp8fbrxmgkefatp10aw.webp");
|
||||
|
||||
var mockData = new Dictionary<Guid, MockData>
|
||||
{
|
||||
{ key1, data1 },
|
||||
};
|
||||
var parser = BuildDefaultSut(mockData);
|
||||
|
||||
// override the no-op behaviour set up in BuildDefaultSut to verify the call shape
|
||||
_imageUrlTokenGenerator.Reset();
|
||||
_imageUrlTokenGenerator
|
||||
.Setup(g => g.RefreshSignature(It.IsAny<string>()))
|
||||
.Returns<string>(_ => "FRESH_URL");
|
||||
|
||||
var html =
|
||||
@"<p><img src=""/media/bdofwokn/77gtp8fbrxmgkefatp10aw.webp?width=500&hmac=stale"" data-udi=""umb://media/395bdc0e8f4d4ad4af7f3a3f6265651e""></p>";
|
||||
|
||||
var parsedHtml = parser.Parse(html);
|
||||
|
||||
// The signer receives the raw attribute value as returned by HtmlAgilityPack (entities not decoded).
|
||||
_imageUrlTokenGenerator.Verify(
|
||||
g => g.RefreshSignature("https://localhost:44331/media/bdofwokn/77gtp8fbrxmgkefatp10aw.webp?width=500&hmac=stale"),
|
||||
Times.Once);
|
||||
StringAssert.Contains(@"src=""FRESH_URL""", parsedHtml);
|
||||
}
|
||||
|
||||
private ApiRichTextMarkupParser BuildDefaultSut(Dictionary<Guid, MockData> mockData)
|
||||
{
|
||||
var contentCacheMock = new Mock<IPublishedContentCache>();
|
||||
@@ -201,11 +238,16 @@ public class ApiRichTextMarkupParserTests
|
||||
_apiContentRouteBuilder.Setup(acrb => acrb.Build(It.IsAny<IPublishedContent>(), It.IsAny<string>()))
|
||||
.Returns<IPublishedContent, string>((content, culture) => mockData[content.Key].ApiContentRoute);
|
||||
|
||||
_imageUrlTokenGenerator = new Mock<IImageUrlTokenGenerator>();
|
||||
_imageUrlTokenGenerator.Setup(g => g.RefreshSignature(It.IsAny<string>()))
|
||||
.Returns<string>(url => url);
|
||||
|
||||
return new ApiRichTextMarkupParser(
|
||||
_apiContentRouteBuilder.Object,
|
||||
_apiMediaUrlProvider.Object,
|
||||
contentCacheMock.Object,
|
||||
mediaCacheMock.Object,
|
||||
_imageUrlTokenGenerator.Object,
|
||||
Mock.Of<ILogger<ApiRichTextMarkupParser>>());
|
||||
}
|
||||
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
// 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;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Media;
|
||||
|
||||
[TestFixture]
|
||||
public class ImageSharpImageUrlTokenGeneratorTests
|
||||
{
|
||||
private const string MediaPath = "/media/1001/img.jpg";
|
||||
|
||||
private static readonly byte[] _keyBytes =
|
||||
{
|
||||
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,
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Returns_Url_Unchanged_When_No_Secret_Configured()
|
||||
{
|
||||
var options = new ImageSharpMiddlewareOptions(); // empty HMACSecretKey
|
||||
var requestAuthorization = BuildRequestAuthorization(options);
|
||||
|
||||
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
|
||||
|
||||
const string url = MediaPath + "?width=400&height=400";
|
||||
Assert.AreEqual(url, generator.RefreshSignature(url));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Url_Unchanged_When_RequestAuthorization_Is_Null()
|
||||
{
|
||||
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
|
||||
|
||||
var generator = new ImageSharpImageUrlTokenGenerator(null, Options.Create(options));
|
||||
|
||||
const string url = MediaPath + "?width=400&height=400";
|
||||
Assert.AreEqual(url, generator.RefreshSignature(url));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_Input_Unchanged()
|
||||
{
|
||||
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
|
||||
var requestAuthorization = BuildRequestAuthorization(options);
|
||||
|
||||
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
|
||||
|
||||
Assert.AreEqual(string.Empty, generator.RefreshSignature(string.Empty));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Appends_Token_When_None_Present()
|
||||
{
|
||||
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
|
||||
var requestAuthorization = BuildRequestAuthorization(options);
|
||||
|
||||
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
|
||||
|
||||
var result = generator.RefreshSignature(MediaPath + "?width=400&height=400");
|
||||
|
||||
StringAssert.StartsWith(MediaPath + "?width=400&height=400&hmac=", result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Replaces_Existing_Stale_Token()
|
||||
{
|
||||
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
|
||||
var requestAuthorization = BuildRequestAuthorization(options);
|
||||
|
||||
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
|
||||
|
||||
var freshFromScratch = generator.RefreshSignature(MediaPath + "?width=400&height=400");
|
||||
var refreshedFromStale = generator.RefreshSignature(MediaPath + "?width=400&height=400&hmac=deadbeefdeadbeef");
|
||||
|
||||
Assert.AreEqual(freshFromScratch, refreshedFromStale);
|
||||
Assert.AreEqual(1, CountOccurrences(refreshedFromStale, "hmac="));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Produces_Same_Token_As_ImageSharpImageUrlGenerator()
|
||||
{
|
||||
// Round-trip: a URL produced by the editor-side generator must round-trip through the signer unchanged.
|
||||
// Pins down that both call sites canonicalise/sign the URL the same way.
|
||||
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
|
||||
var requestAuthorization = BuildRequestAuthorization(options);
|
||||
|
||||
var editorGenerator = new ImageSharpImageUrlGenerator(
|
||||
Array.Empty<string>(),
|
||||
Options.Create(options),
|
||||
requestAuthorization);
|
||||
|
||||
var tokenGenerator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
|
||||
|
||||
var signedByEditor = editorGenerator.GetImageUrl(new ImageUrlGenerationOptions(MediaPath)
|
||||
{
|
||||
Width = 400,
|
||||
Height = 400,
|
||||
});
|
||||
|
||||
Assert.IsNotNull(signedByEditor);
|
||||
Assert.AreEqual(signedByEditor, tokenGenerator.RefreshSignature(signedByEditor!));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Path_With_No_Recognised_Commands_Returns_Unsigned()
|
||||
{
|
||||
// Matches the editor-side behaviour in ImageSharpImageUrlGenerator: when ImageSharp.Web has
|
||||
// no recognised commands to sign, ComputeHMAC returns empty and no token is attached.
|
||||
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
|
||||
var requestAuthorization = BuildRequestAuthorization(options);
|
||||
|
||||
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
|
||||
|
||||
Assert.AreEqual(MediaPath, generator.RefreshSignature(MediaPath));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Preserves_HtmlEntity_Encoded_Ampersands()
|
||||
{
|
||||
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
|
||||
var requestAuthorization = BuildRequestAuthorization(options);
|
||||
|
||||
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
|
||||
|
||||
var result = generator.RefreshSignature(MediaPath + "?width=400&height=400&hmac=stale");
|
||||
|
||||
StringAssert.StartsWith(MediaPath + "?width=400&height=400&hmac=", result);
|
||||
StringAssert.DoesNotContain("hmac=stale", result);
|
||||
Assert.AreEqual(0, CountOccurrences(result, "&hmac="), "ampersands must remain entity-encoded");
|
||||
Assert.AreEqual(1, CountOccurrences(result, "&hmac="), "exactly one fresh token, &-encoded");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Does_Not_Encode_Ampersands_In_The_Path()
|
||||
{
|
||||
// The path is not part of the query and any '&' in it must not be encoded on output.
|
||||
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
|
||||
var requestAuthorization = BuildRequestAuthorization(options);
|
||||
|
||||
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
|
||||
|
||||
// The input uses & in the query (triggers entity-preservation), but the path contains a literal '&'.
|
||||
const string pathWithAmpersand = "/media/foo&bar/img.jpg";
|
||||
var result = generator.RefreshSignature(pathWithAmpersand + "?width=400&height=400");
|
||||
|
||||
StringAssert.StartsWith(pathWithAmpersand + "?width=400&height=400&hmac=", result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Different_Cache_Busters_Do_Not_Bloat_Cache()
|
||||
{
|
||||
// Cache-busters ('v', 'rnd') don't contribute to the HMAC (they're stripped by Sanitize).
|
||||
// Two URLs differing only in their cache-buster should produce the same token. Same token
|
||||
// implies the cache key collapsed both inputs onto a single entry.
|
||||
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
|
||||
var requestAuthorization = BuildRequestAuthorization(options);
|
||||
|
||||
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
|
||||
|
||||
var firstResult = generator.RefreshSignature(MediaPath + "?width=400&v=20240101");
|
||||
var secondResult = generator.RefreshSignature(MediaPath + "?width=400&v=20251231");
|
||||
|
||||
var firstToken = TokenFrom(firstResult);
|
||||
var secondToken = TokenFrom(secondResult);
|
||||
|
||||
Assert.IsNotEmpty(firstToken);
|
||||
Assert.AreEqual(firstToken, secondToken);
|
||||
|
||||
// Each output URL retains its own cache buster.
|
||||
StringAssert.Contains("v=20240101", firstResult);
|
||||
StringAssert.Contains("v=20251231", secondResult);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Subsequent_Calls_Are_Deterministic()
|
||||
{
|
||||
var options = new ImageSharpMiddlewareOptions { HMACSecretKey = _keyBytes };
|
||||
var requestAuthorization = BuildRequestAuthorization(options);
|
||||
|
||||
var generator = new ImageSharpImageUrlTokenGenerator(requestAuthorization, Options.Create(options));
|
||||
|
||||
var first = generator.RefreshSignature(MediaPath + "?width=400");
|
||||
var second = generator.RefreshSignature(MediaPath + "?width=400");
|
||||
|
||||
Assert.AreEqual(first, second);
|
||||
}
|
||||
|
||||
private static RequestAuthorizationUtilities BuildRequestAuthorization(ImageSharpMiddlewareOptions options)
|
||||
=> new(
|
||||
Options.Create(options),
|
||||
new QueryCollectionRequestParser(),
|
||||
[new ResizeWebProcessor()],
|
||||
new CommandParser(Enumerable.Empty<ICommandConverter>()),
|
||||
new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
private static int CountOccurrences(string haystack, string needle)
|
||||
{
|
||||
var count = 0;
|
||||
var idx = 0;
|
||||
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
idx += needle.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string TokenFrom(string signedUrl)
|
||||
{
|
||||
const string marker = "hmac=";
|
||||
var idx = signedUrl.LastIndexOf(marker, StringComparison.Ordinal);
|
||||
return idx < 0 ? string.Empty : signedUrl[(idx + marker.Length)..];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user