From 817a2e129f04cb96ee04bc3515248ccd150bafde Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Fri, 2 Oct 2026 13:18:46 +0200 Subject: [PATCH 1/3] :alembic: add a multi-target test page & enable sizing information to be retrieved (CORS allowed when the test page is enabled) --- .gitignore | 1 - README.md | 8 +- .../ImageProcessing/TestImageResizer.cs | 65 ++ .../Middleware/TestTestPageCorsMiddleware.cs | 185 ++++ src/IRAAS.Tests/TestStartup.cs | 1 + src/IRAAS/ImageProcessing/ImageResizer.cs | 14 + src/IRAAS/ImageProcessing/SizeHeaders.cs | 6 + src/IRAAS/Middleware/Routes.cs | 11 + .../Middleware/TestPageCorsMiddleware.cs | 54 + src/IRAAS/Startup.cs | 3 + tools/iraas-compare.html | 940 ++++++++++++++++++ 11 files changed, 1286 insertions(+), 2 deletions(-) create mode 100644 src/IRAAS.Tests/Middleware/TestTestPageCorsMiddleware.cs create mode 100644 src/IRAAS/ImageProcessing/SizeHeaders.cs create mode 100644 src/IRAAS/Middleware/TestPageCorsMiddleware.cs create mode 100644 tools/iraas-compare.html diff --git a/.gitignore b/.gitignore index 69b9c18..ff4d25a 100644 --- a/.gitignore +++ b/.gitignore @@ -342,7 +342,6 @@ healthchecksdb # Backup folder for Package Reference Convert tool in Visual Studio 2017 MigrationBackup/ -tools .idea build-tools *.swp diff --git a/README.md b/README.md index 2379f1d..6f5ba18 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,11 @@ the parameters sent to the resize service. This can be useful for testing what defaults to use and ensuring that domain whitelists are appropriately set. +For comparing the output of many option combinations side-by-side, +open [tools/iraas-compare.html](tools/iraas-compare.html) directly in a +browser (no web server required) and point it at an IRAAS instance with +`EnableTestPage` set to `true`. + ## Requirements - Node (for build) - dotnet sdk 7.0 or better @@ -43,7 +48,7 @@ MaxInputImageSize | 41943040 (40mb) | The maximum siz MaxOutputImageSize | 41943040 (40mb) | The maximum output size, in bytes, to allow. Outputs which are larger than this are discarded UseDeveloperExceptionPage | false | Flag: when enabled, if IRAAS encounters an error, it will show the developer exception page instead of the default short response UseHttps | false | Flag: when enabled, requests coming in on http will be redirected to the https url you configure in `Urls` -EnableTestPage | false | Flag: when enabled, the path `/test` will serve a (very basic) web page to test resizing images +EnableTestPage | false | Flag: when enabled, the path `/test` will serve a (very basic) web page to test resizing images. GET resize and `/size` responses also allow cross-origin reads (`Access-Control-Allow-Origin: *`, `Access-Control-Expose-Headers: *`, `Timing-Allow-Origin: *`) so that standalone test pages, like `tools/iraas-compare.html`, can read sizes and headers DomainWhitelist | * | Comma-delimited list of domains or domain globs which are allowed as image sources, eg `*.mydomain.com`. Setting this to "*" allows all domains. MaxConcurrency | 0 | The maximum number of image resize operations to perform concurrently. When set to 0, will default to using the number of processors on the host MaxImageFetchTimeInMilliseconds | 10000 (10s) | The maximum time to wait for a remote service to provide a requested image, in milliseconds @@ -54,6 +59,7 @@ SuppressErrorDiagnostics | false | Flag: when set MaxUrlFetchRetries | 1 | The number of times to _retry_ a failed image fetch request. If set to zero, only the initial request is performed. Setting this to a low number like 1 or 2 may work around transient errors from remote image servers AllowInvalidSSLCertificates | false | Flag: when set to true, invalid ssl certificates are ignored when fetching images to resize (useful for dev-testing against self-signed certificates) AllowPostRequests | false | Flag: when set to true, clients may post raw image data and resize parameters to IRAAS and receive the resize result, if they pass other limitations +Verbose | false | Flag: when enabled, resize responses include `IRAAS-Timing-*` headers (milliseconds per processing step), `IRAAS-Resize-Parameter-*` headers (the final parameters used, after defaults are applied) and an `IRAAS-Source-Size` header (source image size, in bytes). The shipped `appsettings.json` enables this PostAuthTokens | "" | A comma-separated list of allowed tokens for Bearer authentication when POST requests are allowed. When it's empty, no POST request will be allowed. You may allow all POST requests by setting this to '*'. ## Operation notes diff --git a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs index c681d6c..149231d 100644 --- a/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs +++ b/src/IRAAS.Tests/ImageProcessing/TestImageResizer.cs @@ -808,6 +808,50 @@ public async Task ShouldIncludeTimingHeader_(string header) } } + [TestFixture] + public class SourceSizeHeader + { + [Test] + public async Task ShouldIncludeSourceSizeWhenVerbose() + { + // Arrange + var data = Resources.Data.FluffyCatBmp; + var fetcher = CreateFetcherFor(data); + var sut = Create(fetcher); + var options = new ImageUrlResizeParameters() + { + Url = GetRandomHttpUrlWithPath(), + Format = "PNG" + }; + // Act + var result = await sut.Resize(options, NoHeaders); + // Assert + Expect(result.Headers) + .To.Contain.Key(SizeHeaders.SourceSize) + .With.Value($"{data.Length}"); + } + + [Test] + public async Task ShouldNotIncludeSourceSizeWhenNotVerbose() + { + // Arrange + var fetcher = CreateFetcherFor(Resources.Data.FluffyCatBmp); + var appSettings = CreateDefaultAppSettingsWithVerboseEnabled(); + appSettings.Verbose.Returns(false); + var sut = Create(fetcher, appSettings); + var options = new ImageUrlResizeParameters() + { + Url = GetRandomHttpUrlWithPath(), + Format = "PNG" + }; + // Act + var result = await sut.Resize(options, NoHeaders); + // Assert + Expect(result.Headers) + .Not.To.Contain.Key(SizeHeaders.SourceSize); + } + } + [Test] public void ShouldThrowIfOutputWillExceedMaxOutputSize() { @@ -875,6 +919,27 @@ private static IUrlFetcher CreateFetcherFor( [TestFixture] public class ResizingImageViaPostedData { + [Test] + public async Task ShouldIncludeSourceSizeWhenVerbose() + { + // Arrange + var data = Resources.Data.FluffyCatBmp; + var sut = Create(); + var resizeParameters = new ImageDataResizeParameters() + { + ImageData = data, + Format = "PNG" + }; + + // Act + var result = await sut.Resize(resizeParameters); + + // Assert + Expect(result.Headers) + .To.Contain.Key(SizeHeaders.SourceSize) + .With.Value($"{data.Length}"); + } + [Test] public void ShouldThrowForNullResizeParameters() { diff --git a/src/IRAAS.Tests/Middleware/TestTestPageCorsMiddleware.cs b/src/IRAAS.Tests/Middleware/TestTestPageCorsMiddleware.cs new file mode 100644 index 0000000..a835b0d --- /dev/null +++ b/src/IRAAS.Tests/Middleware/TestTestPageCorsMiddleware.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using IRAAS.Middleware; +using IRAAS.Tests.ImageProcessing; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using NUnit.Framework; +using PeanutButter.TestUtils.AspNetCore.Builders; +using PeanutButter.TestUtils.AspNetCore.Utils; + +namespace IRAAS.Tests.Middleware; + +[TestFixture] +public class TestTestPageCorsMiddleware +{ + [Test] + public void ShouldImplementIMiddleware() + { + // Arrange + // Act + Expect(typeof(TestPageCorsMiddleware)) + .To.Implement(); + // Assert + } + + [TestFixture] + public class WhenTestPageEnabled + { + [TestCase("/")] + [TestCase("/size")] + [TestCase("/size/")] + public async Task ShouldAllowAnyOriginToReadGetResponsesFrom_( + string path + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = Arena(HttpMethods.Get, path, c => captured = c, out var startResponse); + var sut = Create(TestPageEnabled()); + + // Act + await sut.InvokeAsync(ctx, next); + await startResponse(); + + // Assert + Expect(captured) + .To.Be(ctx); + var headers = ctx.Response.Headers; + Expect(headers["Access-Control-Allow-Origin"].ToString()) + .To.Equal("*"); + Expect(headers["Access-Control-Expose-Headers"].ToString()) + .To.Equal("*"); + Expect(headers["Timing-Allow-Origin"].ToString()) + .To.Equal("*"); + } + + [Test] + public async Task ShouldOverrideCorsHeadersEchoedFromTheImageSource() + { + // Arrange + var (ctx, next) = Arena( + HttpMethods.Get, + "/", + c => c.Response.Headers["Access-Control-Allow-Origin"] = "https://elsewhere.com", + out var startResponse + ); + var sut = Create(TestPageEnabled()); + + // Act + await sut.InvokeAsync(ctx, next); + await startResponse(); + + // Assert + Expect(ctx.Response.Headers["Access-Control-Allow-Origin"].ToString()) + .To.Equal("*"); + } + + [TestCase("POST", "/")] + [TestCase("GET", "/test")] + [TestCase("GET", "/health")] + [TestCase("GET", "/config")] + public async Task ShouldNotAddCorsHeadersFor_( + string method, + string path + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = Arena(method, path, c => captured = c, out var startResponse); + var sut = Create(TestPageEnabled()); + + // Act + await sut.InvokeAsync(ctx, next); + await startResponse(); + + // Assert + Expect(captured) + .To.Be(ctx); + Expect(ctx.Response.Headers.Keys) + .Not.To.Contain("Access-Control-Allow-Origin"); + } + } + + [TestFixture] + public class WhenTestPageDisabled + { + [TestCase("/")] + [TestCase("/size")] + public async Task ShouldNotAddCorsHeadersFor_( + string path + ) + { + // Arrange + HttpContext captured = null; + var (ctx, next) = Arena(HttpMethods.Get, path, c => captured = c, out var startResponse); + var appSettings = Substitute.For() + .WithDefaultSettings() + .WithTestPageDisabled(); + var sut = Create(appSettings); + + // Act + await sut.InvokeAsync(ctx, next); + await startResponse(); + + // Assert + Expect(captured) + .To.Be(ctx); + var headers = ctx.Response.Headers; + Expect(headers.Keys) + .Not.To.Contain("Access-Control-Allow-Origin"); + Expect(headers.Keys) + .Not.To.Contain("Access-Control-Expose-Headers"); + Expect(headers.Keys) + .Not.To.Contain("Timing-Allow-Origin"); + } + } + + private static RequestDelegateTestArena Arena( + string method, + string path, + Action logic, + out Func startResponse + ) + { + // the fake response doesn't run OnStarting handlers itself, so + // capture them and let the test say when the response starts + var onStarting = new List<(Func callback, object state)>(); + startResponse = async () => + { + foreach (var (callback, state) in onStarting) + { + await callback(state); + } + }; + return RequestDelegateTestArenaBuilder.Create() + .WithContext( + HttpContextBuilder.Create() + .WithRequestMethod(method) + .WithRequestPath(path) + .WithRequestQueryParameter("url", GetRandomHttpsUrlWithPath()) + .WithResponse( + HttpResponseBuilder.Create() + .WithOnStartingHandler((callback, state) => onStarting.Add((callback, state))) + .Build() + ) + .Build() + ).WithDelegateLogic(logic) + .Build(); + } + + private static IAppSettings TestPageEnabled() + { + return Substitute.For() + .WithDefaultSettings() + .WithTestPageEnabled(); + } + + private static TestPageCorsMiddleware Create( + IAppSettings appSettings + ) + { + return new TestPageCorsMiddleware(appSettings); + } +} diff --git a/src/IRAAS.Tests/TestStartup.cs b/src/IRAAS.Tests/TestStartup.cs index 7dd451a..6be283e 100644 --- a/src/IRAAS.Tests/TestStartup.cs +++ b/src/IRAAS.Tests/TestStartup.cs @@ -166,6 +166,7 @@ public class MiddlewareTests : TestBase /// private static readonly Type[] ExpectedPipeline = [ + typeof(TestPageCorsMiddleware), typeof(ProductionFallbackExceptionHandlerMiddleware), typeof(MaxClientsMiddleware), typeof(BadHttpRequestExceptionMiddleware), diff --git a/src/IRAAS/ImageProcessing/ImageResizer.cs b/src/IRAAS/ImageProcessing/ImageResizer.cs index 85e4597..3eea051 100644 --- a/src/IRAAS/ImageProcessing/ImageResizer.cs +++ b/src/IRAAS/ImageProcessing/ImageResizer.cs @@ -116,6 +116,7 @@ private StreamAndHeaders ResizeImage( StreamAndHeaders src ) { + var sourceSizeHeaders = SourceSizeHeadersFor(src.Stream); IImageFormat sourceFormat = null; try { @@ -210,11 +211,24 @@ resizeParameters.ReplaceTransparencyWith is not null && new MergeDictionary( src.Headers, timer.Timings, + sourceSizeHeaders, resizeParameters.DumpIf(_appSettings.Verbose) ) ); } + private Dictionary SourceSizeHeadersFor( + Stream source + ) + { + return _appSettings.Verbose + ? new Dictionary + { + [SizeHeaders.SourceSize] = $"{source.Length}" + } + : new Dictionary(); + } + private IResampler DetermineSamplerFor( IActiveImageResizeParameters resizeParameters ) diff --git a/src/IRAAS/ImageProcessing/SizeHeaders.cs b/src/IRAAS/ImageProcessing/SizeHeaders.cs new file mode 100644 index 0000000..7ed3611 --- /dev/null +++ b/src/IRAAS/ImageProcessing/SizeHeaders.cs @@ -0,0 +1,6 @@ +namespace IRAAS.ImageProcessing; + +public static class SizeHeaders +{ + public const string SourceSize = "IRAAS-Source-Size"; +} diff --git a/src/IRAAS/Middleware/Routes.cs b/src/IRAAS/Middleware/Routes.cs index d2da21e..95e3f6f 100644 --- a/src/IRAAS/Middleware/Routes.cs +++ b/src/IRAAS/Middleware/Routes.cs @@ -37,6 +37,17 @@ HttpRequest request ); } + public static bool HasResizeEndpointPath( + HttpRequest request + ) + { + return AnyPathMatches( + request, + "", + "/" + ); + } + public static bool HasSizeEndpointPath( HttpRequest request ) diff --git a/src/IRAAS/Middleware/TestPageCorsMiddleware.cs b/src/IRAAS/Middleware/TestPageCorsMiddleware.cs new file mode 100644 index 0000000..d05f2ba --- /dev/null +++ b/src/IRAAS/Middleware/TestPageCorsMiddleware.cs @@ -0,0 +1,54 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace IRAAS.Middleware; + +/// +/// When the test page is enabled, allows any origin (including a +/// standalone page opened from file://) to read GET resize and size +/// responses, along with their headers and timing information. Applied +/// as the response starts so that it also covers error responses and +/// overrides any CORS headers echoed back from the image source. +/// +public class TestPageCorsMiddleware : IMiddleware +{ + private readonly IAppSettings _appSettings; + + public TestPageCorsMiddleware( + IAppSettings appSettings + ) + { + _appSettings = appSettings; + } + + public Task InvokeAsync( + HttpContext context, + RequestDelegate next + ) + { + if (ShouldAllowCrossOriginReads(context.Request)) + { + var response = context.Response; + response.OnStarting( + () => + { + response.Headers["Access-Control-Allow-Origin"] = "*"; + response.Headers["Access-Control-Expose-Headers"] = "*"; + response.Headers["Timing-Allow-Origin"] = "*"; + return Task.CompletedTask; + } + ); + } + + return next(context); + } + + private bool ShouldAllowCrossOriginReads( + HttpRequest request + ) + { + return _appSettings.EnableTestPage && + HttpMethods.IsGet(request.Method) && + (Routes.HasResizeEndpointPath(request) || Routes.HasSizeEndpointPath(request)); + } +} diff --git a/src/IRAAS/Startup.cs b/src/IRAAS/Startup.cs index 25655ec..9f528ef 100644 --- a/src/IRAAS/Startup.cs +++ b/src/IRAAS/Startup.cs @@ -48,6 +48,9 @@ ILoggerFactory loggerFactory return next(); } ); + // registered first so that every response, including errors + // produced further down the pipeline, gets the cors headers + app.UseMiddleware(); if (env.IsDevelopment() || AppSettings.UseDeveloperExceptionPage) { diff --git a/tools/iraas-compare.html b/tools/iraas-compare.html new file mode 100644 index 0000000..77b30b4 --- /dev/null +++ b/tools/iraas-compare.html @@ -0,0 +1,940 @@ + + + + + + IRAAS Compare + + + + +

IRAAS Compare

+ +
+ + +
+ eg http://localhost:5000. Byte sizes need EnableTestPage=true on that + IRAAS instance; parameters used and timings also need Verbose=true. +
+
+ +
+ + +
One url per line. Blank lines and lines starting with # are ignored.
+
+ +
+

Options

+
+ Every combination of the selected values is tested. Options with nothing selected are left out of the + request, so IRAAS uses its defaults. +
+
+
+ +
+ + + + + +
+ +
+
+ + + + From 29bd569a55ce1a7426dc1fefe459f6cd4c097da0 Mon Sep 17 00:00:00 2001 From: Davyd McColl Date: Fri, 2 Oct 2026 13:40:10 +0200 Subject: [PATCH 2/3] :children_crossing: friendlier compare page auto-detects protocol, just needs an host and optional port --- tools/iraas-compare.html | 218 ++++++++++++++++++++++++++++++++++----- 1 file changed, 195 insertions(+), 23 deletions(-) diff --git a/tools/iraas-compare.html b/tools/iraas-compare.html index 77b30b4..a4ad75f 100644 --- a/tools/iraas-compare.html +++ b/tools/iraas-compare.html @@ -7,8 +7,9 @@