From e725c06ee421a9d5dc09a060ac2ed2d602182d21 Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 11:42:55 -0600 Subject: [PATCH 01/12] Retarget solution to .NET 10 Move the whole solution from multi-targeting net8.0;net6.0 to a single net10.0 target for the Smidge 5 major release. - Directory.Build.props: TargetFrameworks -> net10.0, LangVersion -> latest - Smidge.Core: collapse the net6.0/net8.0 conditional package groups into a single Microsoft.Extensions.* 10.0.9 set - Smidge.InMemory: drop the duplicate Dazinator reference and the now-unneeded System.Text.Encodings.Web security pins (covered by the net10 shared framework) - Smidge.Tests: net10.0 - CI: setup-dotnet 6.0.x/8.0.x -> 10.0.x Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 3 +- src/Directory.Build.props | 4 +- src/Smidge.Core/Smidge.Core.csproj | 22 +- src/Smidge.InMemory/Smidge.InMemory.csproj | 9 - .../AddCompressionHeaderAttribute.cs | 75 ----- .../Controllers/AddExpiryHeadersAttribute.cs | 91 ----- .../Controllers/CheckNotModifiedAttribute.cs | 77 ----- .../CompositeFileCacheFilterAttribute.cs | 87 ----- src/Smidge/Controllers/SmidgeController.cs | 315 ------------------ src/Smidge/Models/BundleModelBinder.cs | 14 - .../Nuglify/NuglifySourceMapController.cs | 51 --- test/Smidge.Tests/Smidge.Tests.csproj | 2 +- 12 files changed, 11 insertions(+), 739 deletions(-) delete mode 100644 src/Smidge/Controllers/AddCompressionHeaderAttribute.cs delete mode 100644 src/Smidge/Controllers/AddExpiryHeadersAttribute.cs delete mode 100644 src/Smidge/Controllers/CheckNotModifiedAttribute.cs delete mode 100644 src/Smidge/Controllers/CompositeFileCacheFilterAttribute.cs delete mode 100644 src/Smidge/Controllers/SmidgeController.cs delete mode 100644 src/Smidge/Models/BundleModelBinder.cs delete mode 100644 src/Smidge/Nuglify/NuglifySourceMapController.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 34dedcd..6272293 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,8 +42,7 @@ jobs: uses: actions/setup-dotnet@v3 with: dotnet-version: | - 6.0.x - 8.0.x + 10.0.x - name: Install GitVersion uses: gittools/actions/gitversion/setup@v3.0.3 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index de80c4d..688beba 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -6,7 +6,7 @@ true true snupkg - 9.0 + latest @@ -23,6 +23,6 @@ 5.0.0 - net8.0;net6.0; + net10.0 diff --git a/src/Smidge.Core/Smidge.Core.csproj b/src/Smidge.Core/Smidge.Core.csproj index 3216bf0..a1f2df9 100644 --- a/src/Smidge.Core/Smidge.Core.csproj +++ b/src/Smidge.Core/Smidge.Core.csproj @@ -13,20 +13,12 @@ - - - - - - - - - - - - - - - + + + + + + + diff --git a/src/Smidge.InMemory/Smidge.InMemory.csproj b/src/Smidge.InMemory/Smidge.InMemory.csproj index d962659..9b76e0d 100644 --- a/src/Smidge.InMemory/Smidge.InMemory.csproj +++ b/src/Smidge.InMemory/Smidge.InMemory.csproj @@ -12,15 +12,6 @@ - - - - - - - - - diff --git a/src/Smidge/Controllers/AddCompressionHeaderAttribute.cs b/src/Smidge/Controllers/AddCompressionHeaderAttribute.cs deleted file mode 100644 index f69f65d..0000000 --- a/src/Smidge/Controllers/AddCompressionHeaderAttribute.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Linq; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.DependencyInjection; -using Smidge.Models; - -namespace Smidge.Controllers -{ - /// - /// Adds the compression headers - /// - public sealed class AddCompressionHeaderAttribute : Attribute, IFilterFactory, IOrderedFilter - { - /// Creates an instance of the executable filter. - /// The request . - /// An instance of the executable filter. - public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) - { - return new AddCompressionFilter( - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService()); - } - - public bool IsReusable => true; - - public int Order { get; set; } - - private class AddCompressionFilter : IActionFilter - { - private readonly IRequestHelper _requestHelper; - private readonly IBundleManager _bundleManager; - - public AddCompressionFilter(IRequestHelper requestHelper, IBundleManager bundleManager) - { - _requestHelper = requestHelper ?? throw new ArgumentNullException(nameof(requestHelper)); - _bundleManager = bundleManager ?? throw new ArgumentNullException(nameof(bundleManager)); - } - - public void OnActionExecuting(ActionExecutingContext context) - { - if (context.ActionArguments.Count == 0) - return; - - //put the model in the context, we'll resolve that after it's executed - if (context.ActionArguments.First().Value is RequestModel file) - context.HttpContext.Items[nameof(AddCompressionHeaderAttribute)] = file; - } - - /// - /// Adds the compression headers - /// - /// - public void OnActionExecuted(ActionExecutedContext context) - { - if (context.Exception != null) return; - - //get the model from the items - if (context.HttpContext.Items.TryGetValue(nameof(AddCompressionHeaderAttribute), out var requestModel) && requestModel is RequestModel file && file.IsBundleFound) - { - var enableCompression = true; - - //check if it's a bundle (not composite file) - if (file is BundleRequestModel bundleRequest && _bundleManager.TryGetValue(bundleRequest.FileKey, out var bundle)) - { - var bundleOptions = bundle.GetBundleOptions(_bundleManager, bundleRequest.Debug); - enableCompression = bundleOptions.CompressResult; - } - - if (enableCompression) - context.HttpContext.Response.AddCompressionResponseHeader(_requestHelper.GetClientCompression(context.HttpContext.Request.Headers)); - } - } - } - } -} diff --git a/src/Smidge/Controllers/AddExpiryHeadersAttribute.cs b/src/Smidge/Controllers/AddExpiryHeadersAttribute.cs deleted file mode 100644 index cf08dae..0000000 --- a/src/Smidge/Controllers/AddExpiryHeadersAttribute.cs +++ /dev/null @@ -1,91 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Smidge.Models; -using System; -using System.Linq; -using Microsoft.AspNetCore.Mvc.Filters; -using Smidge.Hashing; -using Smidge.Options; - -namespace Smidge.Controllers -{ - /// - /// Adds the correct caching expiry headers when the request is not in debug - /// - public sealed class AddExpiryHeadersAttribute : Attribute, IFilterFactory, IOrderedFilter - { - public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) => new AddExpiryHeaderFilter(serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService()); - - public bool IsReusable => true; - - public int Order { get; set; } - - public sealed class AddExpiryHeaderFilter : IActionFilter - { - private readonly IHasher _hasher; - private readonly IBundleManager _bundleManager; - - public AddExpiryHeaderFilter(IHasher hasher, IBundleManager bundleManager) - { - _hasher = hasher; - _bundleManager = bundleManager; - } - - public void OnActionExecuting(ActionExecutingContext context) - { - if (context.ActionArguments.Count == 0) - return; - - //put the model in the context, we'll resolve that after it's executed - if (context.ActionArguments.First().Value is RequestModel file) - context.HttpContext.Items[nameof(AddExpiryHeadersAttribute)] = file; - } - - /// - /// Adds the expiry headers - /// - /// - public void OnActionExecuted(ActionExecutedContext context) - { - if (context.Exception != null) - return; - - //get the model from the items - if (!context.HttpContext.Items.TryGetValue(nameof(AddExpiryHeadersAttribute), out object fileObject) || fileObject is not RequestModel file || !file.IsBundleFound) - return; - - var enableETag = true; - var cacheControlMaxAge = 10 * 24; //10 days - - BundleOptions bundleOptions; - - if (_bundleManager.TryGetValue(file.FileKey, out Bundle b)) - { - bundleOptions = b.GetBundleOptions(_bundleManager, file.Debug); - } - else - { - bundleOptions = file.Debug ? _bundleManager.DefaultBundleOptions.DebugOptions : _bundleManager.DefaultBundleOptions.ProductionOptions; - } - - if (bundleOptions != null) - { - enableETag = bundleOptions.CacheControlOptions.EnableETag; - cacheControlMaxAge = bundleOptions.CacheControlOptions.CacheControlMaxAge; - } - - if (enableETag) - { - var etag = _hasher.Hash(file.FileKey + file.Compression + file.Mime); - context.HttpContext.Response.AddETagResponseHeader(etag); - } - - if (cacheControlMaxAge > 0) - { - context.HttpContext.Response.AddCacheControlResponseHeader(cacheControlMaxAge); - context.HttpContext.Response.AddLastModifiedResponseHeader(file); - context.HttpContext.Response.AddExpiresResponseHeader(cacheControlMaxAge); - } - } - } - } -} diff --git a/src/Smidge/Controllers/CheckNotModifiedAttribute.cs b/src/Smidge/Controllers/CheckNotModifiedAttribute.cs deleted file mode 100644 index 8fb90eb..0000000 --- a/src/Smidge/Controllers/CheckNotModifiedAttribute.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Smidge.Models; -using System; -using System.Linq; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Filters; -using Smidge.Hashing; - -namespace Smidge.Controllers -{ - /// - /// This checks the request headers to see if the response has been modified, if it has not we return a 304 and short circuit the request - /// - public class CheckNotModifiedAttribute : Attribute, IFilterFactory, IOrderedFilter - { - public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) - { - return new CheckNotModifiedFilter(serviceProvider.GetRequiredService()); - } - - public bool IsReusable => true; - - public int Order { get; set; } - - public sealed class CheckNotModifiedFilter : IActionFilter - { - private readonly IHasher _hasher; - - public CheckNotModifiedFilter(IHasher hasher) - { - _hasher = hasher; - } - - public void OnActionExecuting(ActionExecutingContext context) - { - if (context.ActionArguments.Count == 0) - return; - - //put the model in the context, we'll resolve that after it's executed - if (context.ActionArguments.First().Value is RequestModel file) - context.HttpContext.Items[nameof(CheckNotModifiedAttribute)] = file; - } - - /// - /// Adds the expiry headers - /// - /// - public void OnActionExecuted(ActionExecutedContext context) - { - if (context.Exception != null) return; - - //get the model from the items - if (context.HttpContext.Items.TryGetValue(nameof(CheckNotModifiedAttribute), out var requestModel) && requestModel is RequestModel file && file.IsBundleFound) - { - //Don't execute when the request is in Debug - if (file.Debug) - return; - - var etag = _hasher.Hash(file.FileKey + file.Compression + file.Mime); - - var isDifferent = context.HttpContext.Request.HasETagBeenModified(etag); - var hasChanged = context.HttpContext.Request.HasRequestBeenModifiedSince(file.LastFileWriteTime.ToUniversalTime()); - if (!isDifferent || !hasChanged) - { - ReturnNotModified(context); - } - } - } - - private static void ReturnNotModified(ActionExecutedContext context) - { - context.Result = new StatusCodeResult(StatusCodes.Status304NotModified); - } - } - } -} diff --git a/src/Smidge/Controllers/CompositeFileCacheFilterAttribute.cs b/src/Smidge/Controllers/CompositeFileCacheFilterAttribute.cs deleted file mode 100644 index 2d3e5ec..0000000 --- a/src/Smidge/Controllers/CompositeFileCacheFilterAttribute.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; -using Smidge.Models; -using System; -using System.Linq; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace Smidge.Controllers -{ - //TODO: Should this execute when debug = true? - - /// - /// This checks the file system for an already persisted minified, combined, compressed file for the - /// request definition. If there is one it returns that file directly and the controller does not execute. - /// - public sealed class CompositeFileCacheFilterAttribute : Attribute, IFilterFactory, IOrderedFilter - { - public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) - { - return new CacheFilter( - serviceProvider.GetRequiredService()); - } - - public bool IsReusable => true; - - public int Order { get; set; } - - internal static bool TryGetCachedCompositeFileResult(ISmidgeFileSystem fileSystem, string cacheBusterValue, string filesetKey, CompressionType type, string mime, out FileResult result, out DateTime lastWriteTime) - { - result = null; - - var cacheFile = fileSystem.CacheFileSystem.GetCachedCompositeFile(cacheBusterValue, type, filesetKey, out _); - if (cacheFile.Exists) - { - lastWriteTime = cacheFile.LastModified.DateTime; - - if (!string.IsNullOrWhiteSpace(cacheFile.PhysicalPath)) - { - //if physical path is available then it's the physical file system, in which case we'll deliver the file with the PhysicalFileResult - //FilePathResult uses IHttpSendFileFeature which is a native host option for sending static files - result = new PhysicalFileResult(cacheFile.PhysicalPath, mime); - return true; - } - - //deliver the file via stream - result = new FileStreamResult(cacheFile.CreateReadStream(), mime); - return true; - } - - lastWriteTime = DateTime.Now; - return false; - } - - /// - /// The internal filter that performs the lookup - /// - private class CacheFilter : IActionFilter - { - private readonly ISmidgeFileSystem _fileSystem; - - public CacheFilter(ISmidgeFileSystem fileSystem) - { - _fileSystem = fileSystem; - } - - public void OnActionExecuting(ActionExecutingContext context) - { - if (context.ActionArguments.Count == 0) return; - - var firstArg = context.ActionArguments.First().Value; - if (firstArg is RequestModel file && file.IsBundleFound) - { - var cacheBusterValue = file.ParsedPath.CacheBusterValue; - - if (TryGetCachedCompositeFileResult(_fileSystem, cacheBusterValue, file.FileKey, file.Compression, file.Mime, out FileResult result, out DateTime lastWrite)) - { - file.LastFileWriteTime = lastWrite; - context.Result = result; - } - } - } - - public void OnActionExecuted(ActionExecutedContext context) - { } - } - } -} diff --git a/src/Smidge/Controllers/SmidgeController.cs b/src/Smidge/Controllers/SmidgeController.cs deleted file mode 100644 index c967114..0000000 --- a/src/Smidge/Controllers/SmidgeController.cs +++ /dev/null @@ -1,315 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Logging; -using Smidge.Cache; -using Smidge.CompositeFiles; -using Smidge.FileProcessors; -using Smidge.Models; - -namespace Smidge.Controllers -{ - - /// - /// Controller for handling minified/combined responses - /// - [AddCompressionHeader(Order = 0)] - [AddExpiryHeaders(Order = 1)] - [CheckNotModified(Order = 2)] - [CompositeFileCacheFilter(Order = 3)] - [AllowAnonymous] - public class SmidgeController : Controller - { - private static readonly ConcurrentDictionary s_locks = new ConcurrentDictionary(); - - private readonly ISmidgeFileSystem _fileSystem; - private readonly IBundleManager _bundleManager; - private readonly IBundleFileSetGenerator _fileSetGenerator; - private readonly PreProcessPipelineFactory _processorFactory; - private readonly IPreProcessManager _preProcessManager; - private readonly ILogger _logger; - private readonly CacheBusterResolver _cacheBusterResolver; - - /// - /// Constructor - /// - /// - /// - /// - /// - /// - /// - public SmidgeController( - ISmidgeFileSystem fileSystemHelper, - IBundleManager bundleManager, - IBundleFileSetGenerator fileSetGenerator, - PreProcessPipelineFactory processorFactory, - IPreProcessManager preProcessManager, - ILogger logger, - CacheBusterResolver cacheBusterResolver) - { - _fileSystem = fileSystemHelper ?? throw new ArgumentNullException(nameof(fileSystemHelper)); - _bundleManager = bundleManager ?? throw new ArgumentNullException(nameof(bundleManager)); - _fileSetGenerator = fileSetGenerator ?? throw new ArgumentNullException(nameof(fileSetGenerator)); - _processorFactory = processorFactory ?? throw new ArgumentNullException(nameof(processorFactory)); - _preProcessManager = preProcessManager ?? throw new ArgumentNullException(nameof(preProcessManager)); - _logger = logger; - _cacheBusterResolver = cacheBusterResolver; - } - - /// - /// Handles requests for named bundles - /// - /// The bundle model - /// - public async Task Bundle( - [FromServices] BundleRequestModel bundleModel) - { - if (!bundleModel.IsBundleFound || !_bundleManager.TryGetValue(bundleModel.FileKey, out Bundle foundBundle)) - { - return NotFound(); - } - - if (TryGetBundle(bundleModel, out IActionResult actionResult, out string cacheFilePath)) - { - return actionResult; - } - - SemaphoreSlim bundleLock = s_locks.GetOrAdd(foundBundle.Name, s => new SemaphoreSlim(1, 1)); - await bundleLock.WaitAsync(); - try - { - // Double check, might be available now - if (TryGetBundle(bundleModel, out actionResult, out _)) - { - return actionResult; - } - - //the bundle doesn't exist so we'll go get the files, process them and create the bundle - - //get the files for the bundle - IWebFile[] files = _fileSetGenerator.GetOrderedFileSet(foundBundle, - _processorFactory.CreateDefault( - //the file type in the bundle will always be the same - foundBundle.Files[0].DependencyType)) - .ToArray(); - - if (files.Length == 0) - { - return NotFound(); - } - - Options.BundleOptions bundleOptions = foundBundle.GetBundleOptions(_bundleManager, bundleModel.Debug); - - // Validate the cache buster in the case where the file wasn't eagerly created by the view, - // and the request is coming in directly to the controller action. - string cacheBusterValue = bundleModel.ParsedPath.CacheBusterValue; - Type cacheBusterType = bundleOptions.GetCacheBusterType(); - ICacheBuster cacheBuster = _cacheBusterResolver.GetCacheBuster(cacheBusterType); - if (cacheBuster is not TimestampCacheBuster timestampCacheBuster || !timestampCacheBuster.TimestampBased) - { - if (cacheBusterValue != cacheBuster.GetValue()) - { - // We cannot let this continue, someone is trying to spoof the cache buster value, - // which can lead to lots of arbitrary files being created on the server. - _logger.LogWarning( - "An invalid cache buster value {cacheBusterValue} was detected for the bundle {bundleName} which was not produced by the registered cache buster type {cacheBusterType}", - cacheBusterValue, - bundleModel.Bundle.Name, - cacheBusterType); - return BadRequest(); - } - } - - using var bundleContext = new BundleContext(cacheBusterValue, bundleModel, cacheFilePath); - - var watch = new Stopwatch(); - watch.Start(); - _logger.LogDebug($"Processing bundle '{bundleModel.FileKey}', debug? {bundleModel.Debug} ..."); - - //we need to do the minify on the original files - foreach (IWebFile file in files) - { - await _preProcessManager.ProcessAndCacheFileAsync(file, bundleOptions, bundleContext); - } - - //Get each file path to it's hashed location since that is what the pre-processed file will be saved as - IEnumerable fileInfos = files.Select(x => _fileSystem.CacheFileSystem.GetCacheFile( - x, - () => _fileSystem.GetRequiredFileInfo(x), - bundleOptions.FileWatchOptions.Enabled, - Path.GetExtension(x.FilePath), - cacheBusterValue, - out _)); - - using Stream resultStream = await GetCombinedStreamAsync(fileInfos, bundleContext); - - //compress the response (if enabled) - //do not compress anything if it's not enabled in the bundle options - Stream compressedStream = await Compressor.CompressAsync(bundleOptions.CompressResult ? bundleModel.Compression : CompressionType.None, - bundleOptions.CompressionLevel, - resultStream); - - //save the resulting compressed file, if compression is not enabled it will just save the non compressed format - // this persisted file will be used in the CheckNotModifiedAttribute which will short circuit the request and return - // the raw file if it exists for further requests to this path - await CacheCompositeFileAsync(_fileSystem.CacheFileSystem, cacheFilePath, compressedStream); - - _logger.LogDebug($"Processed bundle '{bundleModel.FileKey}' in {watch.ElapsedMilliseconds}ms"); - - //return the stream - return File(compressedStream, bundleModel.Mime); - } - finally - { - // Remove the lock from the dictionary and release the lock. - if (s_locks.TryRemove(foundBundle.Name, out SemaphoreSlim lck)) - { - lck.Release(); - } - } - } - - /// - /// Handles requests for composite files (non-named bundles) - /// - /// - /// - public async Task Composite( - [FromServices] CompositeFileModel file) - { - if (!file.IsBundleFound || !file.ParsedPath.Names.Any()) - { - return NotFound(); - } - - string cacheBusterValue = file.ParsedPath.CacheBusterValue; - IFileInfo cacheFile = _fileSystem.CacheFileSystem.GetCachedCompositeFile(cacheBusterValue, file.Compression, file.FileKey, out string cacheFilePath); - if (cacheFile.Exists) - { - // this is already processed, return it - if (!string.IsNullOrWhiteSpace(cacheFile.PhysicalPath)) - { - // If physical path is available then it's the physical file system, in which case we'll deliver the file with the PhysicalFileResult - // FilePathResult uses IHttpSendFileFeature which is a native host option for sending static files - return PhysicalFile(cacheFile.PhysicalPath, file.Mime); - } - else - { - return File(cacheFile.CreateReadStream(), file.Mime); - } - } - - // Validate the cache buster in the case where the file wasn't eagerly created by the view, - // and the request is coming in directly to the controller action. - Type cacheBusterType = _bundleManager.GetDefaultBundleOptions(file.Debug).GetCacheBusterType(); - ICacheBuster cacheBuster = _cacheBusterResolver.GetCacheBuster(cacheBusterType); - if (cacheBuster is not TimestampCacheBuster timestampCacheBuster || !timestampCacheBuster.TimestampBased) - { - if (cacheBusterValue != cacheBuster.GetValue()) - { - // We cannot let this continue, someone is trying to spoof the cache buster value, - // which can lead to lots of arbitrary files being created on the server. - _logger.LogWarning( - "An invalid cache buster value {cacheBusterValue} was detected for the composite file {compositeFile} which was not produced by the registered cache buster type {cacheBusterType}", - cacheBusterValue, - cacheFilePath, - cacheBusterType); - return BadRequest(); - } - } - - using var bundleContext = new BundleContext(cacheBusterValue, file, cacheFilePath); - IEnumerable files = file.ParsedPath.Names.Select(filePath => - _fileSystem.CacheFileSystem.GetRequiredFileInfo( - $"{file.ParsedPath.CacheBusterValue}/{filePath + file.Extension}")); - - using Stream resultStream = await GetCombinedStreamAsync(files, bundleContext); - Stream compressedStream = await Compressor.CompressAsync(file.Compression, resultStream); - - await CacheCompositeFileAsync(_fileSystem.CacheFileSystem, cacheFilePath, compressedStream); - - return File(compressedStream, file.Mime); - } - - private bool TryGetBundle(BundleRequestModel bundleModel, out IActionResult actionResult, out string cacheFilePath) - { - // TODO: Here or further internally we need to validate the arbitrary value. - string cacheBusterValue = bundleModel.ParsedPath.CacheBusterValue; - - //now we need to determine if this bundle has already been created - IFileInfo cacheFile = _fileSystem.CacheFileSystem.GetCachedCompositeFile(cacheBusterValue, bundleModel.Compression, bundleModel.FileKey, out cacheFilePath); - if (cacheFile.Exists) - { - _logger.LogDebug($"Returning bundle '{bundleModel.FileKey}' from cache"); - - - if (!string.IsNullOrWhiteSpace(cacheFile.PhysicalPath)) - { - //if physical path is available then it's the physical file system, in which case we'll deliver the file with the PhysicalFileResult - //FilePathResult uses IHttpSendFileFeature which is a native host option for sending static files - actionResult = PhysicalFile(cacheFile.PhysicalPath, bundleModel.Mime); - return true; - } - else - { - actionResult = File(cacheFile.CreateReadStream(), bundleModel.Mime); - return true; - } - } - - actionResult = null; - return false; - } - - private static async Task CacheCompositeFileAsync(ICacheFileSystem cacheProvider, string filePath, Stream compositeStream) - { - await cacheProvider.WriteFileAsync(filePath, compositeStream); - if (compositeStream.CanSeek) - { - compositeStream.Position = 0; - } - } - - /// - /// Combines files into a single stream - /// - /// - /// - /// - private async Task GetCombinedStreamAsync(IEnumerable files, BundleContext bundleContext) - { - //TODO: Here we need to be able to prepend/append based on a "BundleContext" (or similar) - - List inputs = null; - try - { - inputs = files.Where(x => x.Exists) - .Select(x => x.CreateReadStream()) - .ToList(); - - string delimeter = bundleContext.BundleRequest.Extension == ".js" ? ";\n" : "\n"; - Stream combined = await bundleContext.GetCombinedStreamAsync(inputs, delimeter); - return combined; - } - finally - { - if (inputs != null) - { - foreach (Stream input in inputs) - { - input.Dispose(); - } - } - } - } - } -} diff --git a/src/Smidge/Models/BundleModelBinder.cs b/src/Smidge/Models/BundleModelBinder.cs deleted file mode 100644 index 7d9aee0..0000000 --- a/src/Smidge/Models/BundleModelBinder.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.AspNetCore.Mvc.ModelBinding; -using System; -using System.Threading.Tasks; - -namespace Smidge.Models -{ - internal class BundleModelBinder : IModelBinder - { - public Task BindModelAsync(ModelBindingContext bindingContext) - { - throw new NotImplementedException(); - } - } -} \ No newline at end of file diff --git a/src/Smidge/Nuglify/NuglifySourceMapController.cs b/src/Smidge/Nuglify/NuglifySourceMapController.cs deleted file mode 100644 index aaba097..0000000 --- a/src/Smidge/Nuglify/NuglifySourceMapController.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; -using Smidge.Cache; -using Smidge.Models; -using Smidge.Options; - -namespace Smidge.Nuglify -{ - public class NuglifySourceMapController : Controller - { - private readonly ISmidgeFileSystem _fileSystem; - private readonly IBundleManager _bundleManager; - - public NuglifySourceMapController(ISmidgeFileSystem fileSystem, IBundleManager bundleManager) - { - _fileSystem = fileSystem; - _bundleManager = bundleManager; - } - - public ActionResult SourceMap([FromServices] BundleRequestModel bundle) - { - if (!bundle.IsBundleFound) - { - return NotFound(); - } - - var sourceMapFile = _fileSystem.CacheFileSystem.GetRequiredFileInfo(bundle.GetSourceMapFilePath()); - - if (sourceMapFile.Exists) - { - if (!string.IsNullOrWhiteSpace(sourceMapFile.PhysicalPath)) - { - //if physical path is available then it's the physical file system, in which case we'll deliver the file with the PhysicalFileResult - //FilePathResult uses IHttpSendFileFeature which is a native host option for sending static files - return PhysicalFile(sourceMapFile.PhysicalPath, "application/json"); - } - else - { - return File(sourceMapFile.CreateReadStream(), "application/json"); - } - } - - return NotFound(); - } - - - } -} diff --git a/test/Smidge.Tests/Smidge.Tests.csproj b/test/Smidge.Tests/Smidge.Tests.csproj index fd91cfa..ac97022 100644 --- a/test/Smidge.Tests/Smidge.Tests.csproj +++ b/test/Smidge.Tests/Smidge.Tests.csproj @@ -1,7 +1,7 @@ - net8.0;net6.0 + net10.0 Smidge.Tests Smidge.Tests false From 57be2d2f0c6d3ff59cfb719d8f96ca750d5b932b Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 11:43:12 -0600 Subject: [PATCH 02/12] Replace MVC controllers with minimal API endpoints Smidge 5 drops its dependency on MVC for serving bundles. The two controllers and their action filters are replaced with minimal API endpoints and endpoint filters, so AddSmidge no longer forces MVC startup on the host. Tag helpers stay in the Smidge package (they keep the only Razor dependency). - Request models now use IHttpContextAccessor + Request.RouteValues instead of the obsolete IActionContextAccessor - The 4 action filters become IEndpointFilters (compression, expiry, not-modified, cache short-circuit), added outer-to-inner in the same order the MVC filter Order produced so behavior is preserved - SmidgeController -> SmidgeRequestHandler and NuglifySourceMapController -> NuglifySourceMapHandler: POCO handlers returning IResult - SmidgeStartup: drop AddMvcCore/AddApplicationPart and the IActionContextAccessor registration; register the handlers; UseSmidge maps three MapGet endpoints with the endpoint-filter chain - Remove the legacy useEndpointRouting/UseMvc branch and parameter (breaking) - Delete the unused BundleModelBinder Verified against the sample app: bundle/composite endpoints return 200 with the correct caching headers, If-None-Match yields 304, and tag helpers still render bundle URLs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Controllers/SmidgeEndpointFilters.cs | 202 ++++++++++++ .../Controllers/SmidgeRequestHandler.cs | 295 ++++++++++++++++++ src/Smidge/Models/BundleRequestModel.cs | 6 +- src/Smidge/Models/CompositeFileModel.cs | 6 +- src/Smidge/Models/RequestModel.cs | 12 +- src/Smidge/Nuglify/NuglifySourceMapHandler.cs | 49 +++ src/Smidge/SmidgeStartup.cs | 75 ++--- 7 files changed, 587 insertions(+), 58 deletions(-) create mode 100644 src/Smidge/Controllers/SmidgeEndpointFilters.cs create mode 100644 src/Smidge/Controllers/SmidgeRequestHandler.cs create mode 100644 src/Smidge/Nuglify/NuglifySourceMapHandler.cs diff --git a/src/Smidge/Controllers/SmidgeEndpointFilters.cs b/src/Smidge/Controllers/SmidgeEndpointFilters.cs new file mode 100644 index 0000000..e51da9a --- /dev/null +++ b/src/Smidge/Controllers/SmidgeEndpointFilters.cs @@ -0,0 +1,202 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Smidge.Hashing; +using Smidge.Models; +using Smidge.Options; + +namespace Smidge.Controllers +{ + /// + /// Checks the file system for an already persisted minified, combined, compressed file for the + /// request definition. If there is one it returns that file directly and the endpoint handler does not execute. + /// + /// + /// This is the inner-most endpoint filter so that its short-circuit behaviour is equivalent to the + /// previous MVC action filter that had the highest Order. + /// + public sealed class CompositeFileCacheEndpointFilter : IEndpointFilter + { + private readonly ISmidgeFileSystem _fileSystem; + + public CompositeFileCacheEndpointFilter(ISmidgeFileSystem fileSystem) + => _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + if (context.Arguments.OfType().FirstOrDefault() is RequestModel file && file.IsBundleFound) + { + var cacheBusterValue = file.ParsedPath.CacheBusterValue; + + if (TryGetCachedCompositeFileResult(_fileSystem, cacheBusterValue, file.FileKey, file.Compression, file.Mime, out IResult result, out DateTime lastWrite)) + { + file.LastFileWriteTime = lastWrite; + + // short-circuit: return the cached file without invoking the handler + return result; + } + } + + return await next(context); + } + + internal static bool TryGetCachedCompositeFileResult(ISmidgeFileSystem fileSystem, string cacheBusterValue, string filesetKey, CompressionType type, string mime, out IResult result, out DateTime lastWriteTime) + { + result = null; + + var cacheFile = fileSystem.CacheFileSystem.GetCachedCompositeFile(cacheBusterValue, type, filesetKey, out _); + if (cacheFile.Exists) + { + lastWriteTime = cacheFile.LastModified.DateTime; + + if (!string.IsNullOrWhiteSpace(cacheFile.PhysicalPath)) + { + //if physical path is available then it's the physical file system, in which case we'll deliver the file with a physical file result + //which uses IHttpSendFileFeature which is a native host option for sending static files + result = Results.File(cacheFile.PhysicalPath, mime); + return true; + } + + //deliver the file via stream + result = Results.Stream(cacheFile.CreateReadStream(), mime); + return true; + } + + lastWriteTime = DateTime.Now; + return false; + } + } + + /// + /// Checks the request headers to see if the response has been modified, if it has not a 304 is returned and the request is short circuited + /// + public sealed class CheckNotModifiedEndpointFilter : IEndpointFilter + { + private readonly IHasher _hasher; + + public CheckNotModifiedEndpointFilter(IHasher hasher) + => _hasher = hasher ?? throw new ArgumentNullException(nameof(hasher)); + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var result = await next(context); + + if (context.Arguments.OfType().FirstOrDefault() is RequestModel file && file.IsBundleFound) + { + //Don't execute when the request is in Debug + if (file.Debug) + return result; + + var etag = _hasher.Hash(file.FileKey + file.Compression + file.Mime); + + var request = context.HttpContext.Request; + var isDifferent = request.HasETagBeenModified(etag); + var hasChanged = request.HasRequestBeenModifiedSince(file.LastFileWriteTime.ToUniversalTime()); + if (!isDifferent || !hasChanged) + { + return Results.StatusCode(StatusCodes.Status304NotModified); + } + } + + return result; + } + } + + /// + /// Adds the correct caching expiry headers when the request is not in debug + /// + public sealed class AddExpiryHeadersEndpointFilter : IEndpointFilter + { + private readonly IHasher _hasher; + private readonly IBundleManager _bundleManager; + + public AddExpiryHeadersEndpointFilter(IHasher hasher, IBundleManager bundleManager) + { + _hasher = hasher ?? throw new ArgumentNullException(nameof(hasher)); + _bundleManager = bundleManager ?? throw new ArgumentNullException(nameof(bundleManager)); + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var result = await next(context); + + if (context.Arguments.OfType().FirstOrDefault() is not RequestModel file || !file.IsBundleFound) + return result; + + var enableETag = true; + var cacheControlMaxAge = 10 * 24; //10 days + + BundleOptions bundleOptions; + + if (_bundleManager.TryGetValue(file.FileKey, out Bundle b)) + { + bundleOptions = b.GetBundleOptions(_bundleManager, file.Debug); + } + else + { + bundleOptions = file.Debug ? _bundleManager.DefaultBundleOptions.DebugOptions : _bundleManager.DefaultBundleOptions.ProductionOptions; + } + + if (bundleOptions != null) + { + enableETag = bundleOptions.CacheControlOptions.EnableETag; + cacheControlMaxAge = bundleOptions.CacheControlOptions.CacheControlMaxAge; + } + + var response = context.HttpContext.Response; + + if (enableETag) + { + var etag = _hasher.Hash(file.FileKey + file.Compression + file.Mime); + response.AddETagResponseHeader(etag); + } + + if (cacheControlMaxAge > 0) + { + response.AddCacheControlResponseHeader(cacheControlMaxAge); + response.AddLastModifiedResponseHeader(file); + response.AddExpiresResponseHeader(cacheControlMaxAge); + } + + return result; + } + } + + /// + /// Adds the compression headers + /// + public sealed class AddCompressionHeaderEndpointFilter : IEndpointFilter + { + private readonly IRequestHelper _requestHelper; + private readonly IBundleManager _bundleManager; + + public AddCompressionHeaderEndpointFilter(IRequestHelper requestHelper, IBundleManager bundleManager) + { + _requestHelper = requestHelper ?? throw new ArgumentNullException(nameof(requestHelper)); + _bundleManager = bundleManager ?? throw new ArgumentNullException(nameof(bundleManager)); + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var result = await next(context); + + if (context.Arguments.OfType().FirstOrDefault() is RequestModel file && file.IsBundleFound) + { + var enableCompression = true; + + //check if it's a bundle (not composite file) + if (file is BundleRequestModel bundleRequest && _bundleManager.TryGetValue(bundleRequest.FileKey, out var bundle)) + { + var bundleOptions = bundle.GetBundleOptions(_bundleManager, bundleRequest.Debug); + enableCompression = bundleOptions.CompressResult; + } + + if (enableCompression) + context.HttpContext.Response.AddCompressionResponseHeader(_requestHelper.GetClientCompression(context.HttpContext.Request.Headers)); + } + + return result; + } + } +} diff --git a/src/Smidge/Controllers/SmidgeRequestHandler.cs b/src/Smidge/Controllers/SmidgeRequestHandler.cs new file mode 100644 index 0000000..2561003 --- /dev/null +++ b/src/Smidge/Controllers/SmidgeRequestHandler.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using Smidge.Cache; +using Smidge.CompositeFiles; +using Smidge.FileProcessors; +using Smidge.Models; + +namespace Smidge.Controllers +{ + + /// + /// Handles requests for minified/combined responses. + /// + /// + /// This was previously an MVC controller. For Smidge 5 it is a lightweight POCO handler invoked directly + /// from minimal API endpoints, so Smidge no longer requires MVC. + /// + public sealed class SmidgeRequestHandler + { + private static readonly ConcurrentDictionary s_locks = new ConcurrentDictionary(); + + private readonly ISmidgeFileSystem _fileSystem; + private readonly IBundleManager _bundleManager; + private readonly IBundleFileSetGenerator _fileSetGenerator; + private readonly PreProcessPipelineFactory _processorFactory; + private readonly IPreProcessManager _preProcessManager; + private readonly ILogger _logger; + private readonly CacheBusterResolver _cacheBusterResolver; + + public SmidgeRequestHandler( + ISmidgeFileSystem fileSystemHelper, + IBundleManager bundleManager, + IBundleFileSetGenerator fileSetGenerator, + PreProcessPipelineFactory processorFactory, + IPreProcessManager preProcessManager, + ILogger logger, + CacheBusterResolver cacheBusterResolver) + { + _fileSystem = fileSystemHelper ?? throw new ArgumentNullException(nameof(fileSystemHelper)); + _bundleManager = bundleManager ?? throw new ArgumentNullException(nameof(bundleManager)); + _fileSetGenerator = fileSetGenerator ?? throw new ArgumentNullException(nameof(fileSetGenerator)); + _processorFactory = processorFactory ?? throw new ArgumentNullException(nameof(processorFactory)); + _preProcessManager = preProcessManager ?? throw new ArgumentNullException(nameof(preProcessManager)); + _logger = logger; + _cacheBusterResolver = cacheBusterResolver; + } + + /// + /// Handles requests for named bundles + /// + public async Task Bundle(BundleRequestModel bundleModel) + { + if (!bundleModel.IsBundleFound || !_bundleManager.TryGetValue(bundleModel.FileKey, out Bundle foundBundle)) + { + return Results.NotFound(); + } + + if (TryGetBundle(bundleModel, out IResult result, out string cacheFilePath)) + { + return result; + } + + SemaphoreSlim bundleLock = s_locks.GetOrAdd(foundBundle.Name, s => new SemaphoreSlim(1, 1)); + await bundleLock.WaitAsync(); + try + { + // Double check, might be available now + if (TryGetBundle(bundleModel, out result, out _)) + { + return result; + } + + //the bundle doesn't exist so we'll go get the files, process them and create the bundle + + //get the files for the bundle + IWebFile[] files = _fileSetGenerator.GetOrderedFileSet(foundBundle, + _processorFactory.CreateDefault( + //the file type in the bundle will always be the same + foundBundle.Files[0].DependencyType)) + .ToArray(); + + if (files.Length == 0) + { + return Results.NotFound(); + } + + Options.BundleOptions bundleOptions = foundBundle.GetBundleOptions(_bundleManager, bundleModel.Debug); + + // Validate the cache buster in the case where the file wasn't eagerly created by the view, + // and the request is coming in directly to the handler. + string cacheBusterValue = bundleModel.ParsedPath.CacheBusterValue; + Type cacheBusterType = bundleOptions.GetCacheBusterType(); + ICacheBuster cacheBuster = _cacheBusterResolver.GetCacheBuster(cacheBusterType); + if (cacheBuster is not TimestampCacheBuster timestampCacheBuster || !timestampCacheBuster.TimestampBased) + { + if (cacheBusterValue != cacheBuster.GetValue()) + { + // We cannot let this continue, someone is trying to spoof the cache buster value, + // which can lead to lots of arbitrary files being created on the server. + _logger.LogWarning( + "An invalid cache buster value {cacheBusterValue} was detected for the bundle {bundleName} which was not produced by the registered cache buster type {cacheBusterType}", + cacheBusterValue, + bundleModel.Bundle.Name, + cacheBusterType); + return Results.BadRequest(); + } + } + + using var bundleContext = new BundleContext(cacheBusterValue, bundleModel, cacheFilePath); + + var watch = new Stopwatch(); + watch.Start(); + _logger.LogDebug($"Processing bundle '{bundleModel.FileKey}', debug? {bundleModel.Debug} ..."); + + //we need to do the minify on the original files + foreach (IWebFile file in files) + { + await _preProcessManager.ProcessAndCacheFileAsync(file, bundleOptions, bundleContext); + } + + //Get each file path to it's hashed location since that is what the pre-processed file will be saved as + IEnumerable fileInfos = files.Select(x => _fileSystem.CacheFileSystem.GetCacheFile( + x, + () => _fileSystem.GetRequiredFileInfo(x), + bundleOptions.FileWatchOptions.Enabled, + Path.GetExtension(x.FilePath), + cacheBusterValue, + out _)); + + using Stream resultStream = await GetCombinedStreamAsync(fileInfos, bundleContext); + + //compress the response (if enabled) + //do not compress anything if it's not enabled in the bundle options + Stream compressedStream = await Compressor.CompressAsync(bundleOptions.CompressResult ? bundleModel.Compression : CompressionType.None, + bundleOptions.CompressionLevel, + resultStream); + + //save the resulting compressed file, if compression is not enabled it will just save the non compressed format + // this persisted file will be used in the CheckNotModifiedEndpointFilter which will short circuit the request and return + // the raw file if it exists for further requests to this path + await CacheCompositeFileAsync(_fileSystem.CacheFileSystem, cacheFilePath, compressedStream); + + _logger.LogDebug($"Processed bundle '{bundleModel.FileKey}' in {watch.ElapsedMilliseconds}ms"); + + //return the stream + return Results.Stream(compressedStream, bundleModel.Mime); + } + finally + { + // Remove the lock from the dictionary and release the lock. + if (s_locks.TryRemove(foundBundle.Name, out SemaphoreSlim lck)) + { + lck.Release(); + } + } + } + + /// + /// Handles requests for composite files (non-named bundles) + /// + public async Task Composite(CompositeFileModel file) + { + if (!file.IsBundleFound || !file.ParsedPath.Names.Any()) + { + return Results.NotFound(); + } + + string cacheBusterValue = file.ParsedPath.CacheBusterValue; + IFileInfo cacheFile = _fileSystem.CacheFileSystem.GetCachedCompositeFile(cacheBusterValue, file.Compression, file.FileKey, out string cacheFilePath); + if (cacheFile.Exists) + { + // this is already processed, return it + if (!string.IsNullOrWhiteSpace(cacheFile.PhysicalPath)) + { + // If physical path is available then it's the physical file system, in which case we'll deliver the file with the physical file result + // which uses IHttpSendFileFeature which is a native host option for sending static files + return Results.File(cacheFile.PhysicalPath, file.Mime); + } + else + { + return Results.Stream(cacheFile.CreateReadStream(), file.Mime); + } + } + + // Validate the cache buster in the case where the file wasn't eagerly created by the view, + // and the request is coming in directly to the handler. + Type cacheBusterType = _bundleManager.GetDefaultBundleOptions(file.Debug).GetCacheBusterType(); + ICacheBuster cacheBuster = _cacheBusterResolver.GetCacheBuster(cacheBusterType); + if (cacheBuster is not TimestampCacheBuster timestampCacheBuster || !timestampCacheBuster.TimestampBased) + { + if (cacheBusterValue != cacheBuster.GetValue()) + { + // We cannot let this continue, someone is trying to spoof the cache buster value, + // which can lead to lots of arbitrary files being created on the server. + _logger.LogWarning( + "An invalid cache buster value {cacheBusterValue} was detected for the composite file {compositeFile} which was not produced by the registered cache buster type {cacheBusterType}", + cacheBusterValue, + cacheFilePath, + cacheBusterType); + return Results.BadRequest(); + } + } + + using var bundleContext = new BundleContext(cacheBusterValue, file, cacheFilePath); + IEnumerable files = file.ParsedPath.Names.Select(filePath => + _fileSystem.CacheFileSystem.GetRequiredFileInfo( + $"{file.ParsedPath.CacheBusterValue}/{filePath + file.Extension}")); + + using Stream resultStream = await GetCombinedStreamAsync(files, bundleContext); + Stream compressedStream = await Compressor.CompressAsync(file.Compression, resultStream); + + await CacheCompositeFileAsync(_fileSystem.CacheFileSystem, cacheFilePath, compressedStream); + + return Results.Stream(compressedStream, file.Mime); + } + + private bool TryGetBundle(BundleRequestModel bundleModel, out IResult result, out string cacheFilePath) + { + // TODO: Here or further internally we need to validate the arbitrary value. + string cacheBusterValue = bundleModel.ParsedPath.CacheBusterValue; + + //now we need to determine if this bundle has already been created + IFileInfo cacheFile = _fileSystem.CacheFileSystem.GetCachedCompositeFile(cacheBusterValue, bundleModel.Compression, bundleModel.FileKey, out cacheFilePath); + if (cacheFile.Exists) + { + _logger.LogDebug($"Returning bundle '{bundleModel.FileKey}' from cache"); + + + if (!string.IsNullOrWhiteSpace(cacheFile.PhysicalPath)) + { + //if physical path is available then it's the physical file system, in which case we'll deliver the file with the physical file result + //which uses IHttpSendFileFeature which is a native host option for sending static files + result = Results.File(cacheFile.PhysicalPath, bundleModel.Mime); + return true; + } + else + { + result = Results.Stream(cacheFile.CreateReadStream(), bundleModel.Mime); + return true; + } + } + + result = null; + return false; + } + + private static async Task CacheCompositeFileAsync(ICacheFileSystem cacheProvider, string filePath, Stream compositeStream) + { + await cacheProvider.WriteFileAsync(filePath, compositeStream); + if (compositeStream.CanSeek) + { + compositeStream.Position = 0; + } + } + + /// + /// Combines files into a single stream + /// + private async Task GetCombinedStreamAsync(IEnumerable files, BundleContext bundleContext) + { + //TODO: Here we need to be able to prepend/append based on a "BundleContext" (or similar) + + List inputs = null; + try + { + inputs = files.Where(x => x.Exists) + .Select(x => x.CreateReadStream()) + .ToList(); + + string delimeter = bundleContext.BundleRequest.Extension == ".js" ? ";\n" : "\n"; + Stream combined = await bundleContext.GetCombinedStreamAsync(inputs, delimeter); + return combined; + } + finally + { + if (inputs != null) + { + foreach (Stream input in inputs) + { + input.Dispose(); + } + } + } + } + } +} diff --git a/src/Smidge/Models/BundleRequestModel.cs b/src/Smidge/Models/BundleRequestModel.cs index 4b0dea4..d942c3e 100644 --- a/src/Smidge/Models/BundleRequestModel.cs +++ b/src/Smidge/Models/BundleRequestModel.cs @@ -1,5 +1,5 @@ using System.Linq; -using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Http; using Smidge.CompositeFiles; namespace Smidge.Models @@ -10,8 +10,8 @@ namespace Smidge.Models /// public class BundleRequestModel : RequestModel { - public BundleRequestModel(IUrlManager urlManager, IActionContextAccessor accessor, IRequestHelper requestHelper, IBundleManager bundleManager) - : base("bundle", urlManager, accessor, requestHelper) + public BundleRequestModel(IUrlManager urlManager, IHttpContextAccessor httpContextAccessor, IRequestHelper requestHelper, IBundleManager bundleManager) + : base("bundle", urlManager, httpContextAccessor, requestHelper) { //TODO: Pretty sure if we want to control the caching of the file, we'll have to retrieve the bundle definition here // In reality we'll need to do that anyways if we want to support load balancing! diff --git a/src/Smidge/Models/CompositeFileModel.cs b/src/Smidge/Models/CompositeFileModel.cs index 1d850fe..de04766 100644 --- a/src/Smidge/Models/CompositeFileModel.cs +++ b/src/Smidge/Models/CompositeFileModel.cs @@ -1,5 +1,5 @@ using Smidge.CompositeFiles; -using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Http; using Smidge.Hashing; namespace Smidge.Models @@ -7,8 +7,8 @@ namespace Smidge.Models public class CompositeFileModel : RequestModel { - public CompositeFileModel(IHasher hasher, IUrlManager urlManager, IActionContextAccessor accessor, IRequestHelper requestHelper) - : base("file", urlManager, accessor, requestHelper) + public CompositeFileModel(IHasher hasher, IUrlManager urlManager, IHttpContextAccessor httpContextAccessor, IRequestHelper requestHelper) + : base("file", urlManager, httpContextAccessor, requestHelper) { if (!IsBundleFound) { diff --git a/src/Smidge/Models/RequestModel.cs b/src/Smidge/Models/RequestModel.cs index 7f88df4..bdf698b 100644 --- a/src/Smidge/Models/RequestModel.cs +++ b/src/Smidge/Models/RequestModel.cs @@ -1,6 +1,6 @@ using Smidge.CompositeFiles; using System; -using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Http; namespace Smidge.Models { @@ -9,19 +9,21 @@ namespace Smidge.Models /// public abstract class RequestModel : IRequestModel { - protected RequestModel(string valueName, IUrlManager urlManager, IActionContextAccessor accessor, IRequestHelper requestHelper) + protected RequestModel(string valueName, IUrlManager urlManager, IHttpContextAccessor httpContextAccessor, IRequestHelper requestHelper) { if (string.IsNullOrWhiteSpace(valueName)) throw new ArgumentException("message", nameof(valueName)); if (urlManager is null) throw new ArgumentNullException(nameof(urlManager)); - if (accessor is null)throw new ArgumentNullException(nameof(accessor)); + if (httpContextAccessor is null) throw new ArgumentNullException(nameof(httpContextAccessor)); if (requestHelper is null)throw new ArgumentNullException(nameof(requestHelper)); + var request = httpContextAccessor.HttpContext.Request; + //default LastFileWriteTime = DateTime.MinValue; - Compression = requestHelper.GetClientCompression(accessor.ActionContext.HttpContext.Request.Headers); + Compression = requestHelper.GetClientCompression(request.Headers); - var bundleId = (string)accessor.ActionContext.RouteData.Values[valueName]; + var bundleId = (string)request.RouteValues[valueName]; ParsedPath = urlManager.ParsePath(bundleId); if (ParsedPath == null) diff --git a/src/Smidge/Nuglify/NuglifySourceMapHandler.cs b/src/Smidge/Nuglify/NuglifySourceMapHandler.cs new file mode 100644 index 0000000..37acfcd --- /dev/null +++ b/src/Smidge/Nuglify/NuglifySourceMapHandler.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Http; +using Smidge.Cache; +using Smidge.Models; + +namespace Smidge.Nuglify +{ + /// + /// Handles requests for Nuglify generated source map files. + /// + /// + /// This was previously an MVC controller. For Smidge 5 it is a lightweight POCO handler invoked directly + /// from a minimal API endpoint. + /// + public sealed class NuglifySourceMapHandler + { + private readonly ISmidgeFileSystem _fileSystem; + + public NuglifySourceMapHandler(ISmidgeFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + public IResult SourceMap(BundleRequestModel bundle) + { + if (!bundle.IsBundleFound) + { + return Results.NotFound(); + } + + var sourceMapFile = _fileSystem.CacheFileSystem.GetRequiredFileInfo(bundle.GetSourceMapFilePath()); + + if (sourceMapFile.Exists) + { + if (!string.IsNullOrWhiteSpace(sourceMapFile.PhysicalPath)) + { + //if physical path is available then it's the physical file system, in which case we'll deliver the file with the physical file result + //which uses IHttpSendFileFeature which is a native host option for sending static files + return Results.File(sourceMapFile.PhysicalPath, "application/json"); + } + else + { + return Results.Stream(sourceMapFile.CreateReadStream(), "application/json"); + } + } + + return Results.NotFound(); + } + } +} diff --git a/src/Smidge/SmidgeStartup.cs b/src/Smidge/SmidgeStartup.cs index 49a8059..7f0011b 100644 --- a/src/Smidge/SmidgeStartup.cs +++ b/src/Smidge/SmidgeStartup.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -11,6 +11,7 @@ using NUglify.Css; using Smidge.Cache; using Smidge.CompositeFiles; +using Smidge.Controllers; using Smidge.FileProcessors; using Smidge.Hashing; using Smidge.Models; @@ -34,7 +35,6 @@ public static class SmidgeStartup public static IServiceCollection AddSmidge(this IServiceCollection services, IConfiguration smidgeConfiguration = null, NuglifySettings nuglifySettings = null) { services.TryAddSingleton(); - services.TryAddSingleton(); services.AddTransient, SmidgeOptionsSetup>(); @@ -100,68 +100,49 @@ public static IServiceCollection AddSmidge(this IServiceCollection services, ICo services.AddSingleton(); services.AddSingleton(); - //Add the controller models as DI services - these get auto created for model binding + //Add the request models as DI services - these get resolved per request and read route/header data from the current HttpContext services.AddTransient(); services.AddTransient(); - // NOTE: This wasn't explicitly requred for app previous to .net core 3, however it seems like it should have always been there for - // previous versions anyways. Seems sort of odd that this ever worked without it? - var builder = services.AddMvcCore(); - builder.AddApplicationPart(typeof(SmidgeStartup).Assembly); + //Request handlers invoked directly from the minimal API endpoints (previously MVC controllers) + services.AddSingleton(); + services.AddSingleton(); return services; } - public static void UseSmidge(this IApplicationBuilder app, Action configureBundles = null, bool useEndpointRouting = true) + public static void UseSmidge(this IApplicationBuilder app, Action configureBundles = null) { //Creates custom routes var options = app.ApplicationServices.GetRequiredService>(); - //NOTE: It's no longer polite to just call UseMVC as it enables things that the developer may - //not need and the dev must disable EndpointRouting - so we let the dev decide. - //with core 3.0 you have to explicitly disable EndpointRouting se we default to on here - if (useEndpointRouting) + //Map the Smidge endpoints using minimal APIs. The behaviour that was previously implemented with MVC + //action filters is now implemented with endpoint filters. The filters are added outer-to-inner in the + //same execution order the MVC filters ran (compression, expiry, not-modified, then the cache short-circuit). + app.UseEndpoints(endpoints => { - app.UseEndpoints(endpoints => - { - endpoints.MapControllerRoute( - name: "SmidgeComposite", - pattern: options.Value.UrlOptions.CompositeFilePath + "/{file}", - defaults: new { controller = "Smidge", action = "Composite" }); - endpoints.MapControllerRoute( - name: "SmidgeBundle", - pattern: options.Value.UrlOptions.BundleFilePath + "/{bundle}", - defaults: new { controller = "Smidge", action = "Bundle" }); - endpoints.MapControllerRoute( - name: "SmidgeNuglifySourceMap", - pattern: options.Value.UrlOptions.BundleFilePath + "/nmap/{bundle}", - defaults: new { controller = "NuglifySourceMap", action = "SourceMap" }); - }); - - } - else - { - - app.UseMvc(routes => - { - routes.MapRoute( - "SmidgeComposite", + endpoints.MapGet( options.Value.UrlOptions.CompositeFilePath + "/{file}", - new { controller = "Smidge", action = "Composite" }); + ([FromServices] CompositeFileModel file, [FromServices] SmidgeRequestHandler handler) => handler.Composite(file)) + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); - routes.MapRoute( - "SmidgeBundle", + endpoints.MapGet( options.Value.UrlOptions.BundleFilePath + "/{bundle}", - new { controller = "Smidge", action = "Bundle" }); - - routes.MapRoute( - "SmidgeNuglifySourceMap", - options.Value.UrlOptions.BundleFilePath + "/nmap/{bundle}", - new { controller = "NuglifySourceMap", action = "SourceMap" }); - }); - } + ([FromServices] BundleRequestModel bundle, [FromServices] SmidgeRequestHandler handler) => handler.Bundle(bundle)) + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter() + .AddEndpointFilter(); + + endpoints.MapGet( + options.Value.UrlOptions.BundleFilePath + "/nmap/{bundle}", + ([FromServices] BundleRequestModel bundle, [FromServices] NuglifySourceMapHandler handler) => handler.SourceMap(bundle)); + }); if (configureBundles != null) { From 4930231f5d15213b16362339a3f06d7f7aa2940c Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 11:54:07 -0600 Subject: [PATCH 03/12] Return 404 instead of 500 for missing cached/source-map files Requests for composite files and Nuglify source maps could throw an unhandled FileNotFoundException that surfaced as a 500. Because the composite URL and source-map requests contain client-supplied values (and browsers request source maps lazily), this was easy to trigger repeatedly - a denial-of-service vector reported in #199 and the 500 seen for the notfound-map scenario in #185. Adds a non-throwing ICacheFileSystem.GetFileInfo(string) alongside the existing GetRequiredFileInfo (which stays throwing for genuine internal invariants). The composite and source-map request handlers now use the non-throwing lookup and return a graceful 404 (with a log entry) when a requested file is missing or stale. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Smidge.Core/Cache/ICacheFileSystem.cs | 16 ++++++++++++ .../Cache/PhysicalFileCacheFileSystem.cs | 2 ++ .../ConfiguredCacheFileSystem.cs | 3 +++ src/Smidge.InMemory/MemoryCacheFileSystem.cs | 2 ++ .../Controllers/SmidgeRequestHandler.cs | 26 ++++++++++++++++--- src/Smidge/Nuglify/NuglifySourceMapHandler.cs | 14 ++++++++-- 6 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/Smidge.Core/Cache/ICacheFileSystem.cs b/src/Smidge.Core/Cache/ICacheFileSystem.cs index d0527d5..d48f5a5 100644 --- a/src/Smidge.Core/Cache/ICacheFileSystem.cs +++ b/src/Smidge.Core/Cache/ICacheFileSystem.cs @@ -12,7 +12,23 @@ namespace Smidge.Cache /// public interface ICacheFileSystem { + /// + /// Gets the for a cached file, throwing a if it does not exist. + /// + /// + /// Use this only when the file is expected to exist as an internal invariant. For request handling where the + /// path is (or can be) client supplied, use and check + /// so that a missing/stale/spoofed file results in a graceful 404 instead of an unhandled 500. + /// IFileInfo GetRequiredFileInfo(string filePath); + + /// + /// Gets the for a cached file without throwing when it does not exist. + /// + /// + /// The returned may have set to false; callers must check it. + /// + IFileInfo GetFileInfo(string filePath); Task ClearCachedCompositeFileAsync(string cacheBusterValue, CompressionType type, string filesetKey); IFileInfo GetCachedCompositeFile(string cacheBusterValue, CompressionType type, string filesetKey, out string filePath); IFileInfo GetCacheFile(IWebFile file, Func sourceFile, bool fileWatchEnabled, string extension, string cacheBusterValue, out string filePath); diff --git a/src/Smidge.Core/Cache/PhysicalFileCacheFileSystem.cs b/src/Smidge.Core/Cache/PhysicalFileCacheFileSystem.cs index e568ae6..99bc61e 100644 --- a/src/Smidge.Core/Cache/PhysicalFileCacheFileSystem.cs +++ b/src/Smidge.Core/Cache/PhysicalFileCacheFileSystem.cs @@ -48,6 +48,8 @@ public IFileInfo GetRequiredFileInfo(string filePath) return fileInfo; } + public IFileInfo GetFileInfo(string filePath) => _fileProvider.GetFileInfo(filePath); + private string GetCompositeFilePath(string cacheBusterValue, CompressionType type, string filesetKey) => $"{cacheBusterValue}/{type}/{filesetKey}.s"; public Task ClearCachedCompositeFileAsync(string cacheBusterValue, CompressionType type, string filesetKey) diff --git a/src/Smidge.InMemory/ConfiguredCacheFileSystem.cs b/src/Smidge.InMemory/ConfiguredCacheFileSystem.cs index 219b220..91aaa5c 100644 --- a/src/Smidge.InMemory/ConfiguredCacheFileSystem.cs +++ b/src/Smidge.InMemory/ConfiguredCacheFileSystem.cs @@ -54,6 +54,9 @@ public IFileInfo GetCacheFile(IWebFile file, Func sourceFile, bool fi public IFileInfo GetRequiredFileInfo(string filePath) => _wrapped.GetRequiredFileInfo(filePath); + public IFileInfo GetFileInfo(string filePath) + => _wrapped.GetFileInfo(filePath); + public Task WriteFileAsync(string filePath, string contents) => _wrapped.WriteFileAsync(filePath, contents); diff --git a/src/Smidge.InMemory/MemoryCacheFileSystem.cs b/src/Smidge.InMemory/MemoryCacheFileSystem.cs index 38783f2..3b67086 100644 --- a/src/Smidge.InMemory/MemoryCacheFileSystem.cs +++ b/src/Smidge.InMemory/MemoryCacheFileSystem.cs @@ -37,6 +37,8 @@ public IFileInfo GetRequiredFileInfo(string filePath) return fileInfo; } + public IFileInfo GetFileInfo(string filePath) => _fileProvider.GetFileInfo(filePath); + private string GetCompositeFilePath(string cacheBusterValue, CompressionType type, string filesetKey) => $"{cacheBusterValue}/{type}/{filesetKey + ".s"}"; diff --git a/src/Smidge/Controllers/SmidgeRequestHandler.cs b/src/Smidge/Controllers/SmidgeRequestHandler.cs index 2561003..2a4409b 100644 --- a/src/Smidge/Controllers/SmidgeRequestHandler.cs +++ b/src/Smidge/Controllers/SmidgeRequestHandler.cs @@ -211,9 +211,29 @@ public async Task Composite(CompositeFileModel file) } using var bundleContext = new BundleContext(cacheBusterValue, file, cacheFilePath); - IEnumerable files = file.ParsedPath.Names.Select(filePath => - _fileSystem.CacheFileSystem.GetRequiredFileInfo( - $"{file.ParsedPath.CacheBusterValue}/{filePath + file.Extension}")); + + // Resolve each requested file from the cache without throwing. The composite URL contains client + // supplied file hashes, so a stale cache (e.g. after an app restart when using the in-memory cache) + // or a deliberately malformed request can reference files that don't exist. Previously this threw a + // FileNotFoundException which surfaced as an unhandled 500 and could be triggered repeatedly (a DoS + // vector - see issue #199). Instead we return a graceful 404 when any requested file is missing. + var files = new List(file.ParsedPath.Names.Count()); + foreach (var filePath in file.ParsedPath.Names) + { + var fileInfo = _fileSystem.CacheFileSystem.GetFileInfo( + $"{file.ParsedPath.CacheBusterValue}/{filePath + file.Extension}"); + + if (!fileInfo.Exists) + { + _logger.LogWarning( + "The requested composite file {CompositeFile} references a file {FilePath} that does not exist in the cache. Returning 404.", + cacheFilePath, + filePath); + return Results.NotFound(); + } + + files.Add(fileInfo); + } using Stream resultStream = await GetCombinedStreamAsync(files, bundleContext); Stream compressedStream = await Compressor.CompressAsync(file.Compression, resultStream); diff --git a/src/Smidge/Nuglify/NuglifySourceMapHandler.cs b/src/Smidge/Nuglify/NuglifySourceMapHandler.cs index 37acfcd..cb05d9a 100644 --- a/src/Smidge/Nuglify/NuglifySourceMapHandler.cs +++ b/src/Smidge/Nuglify/NuglifySourceMapHandler.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Smidge.Cache; using Smidge.Models; @@ -14,10 +15,12 @@ namespace Smidge.Nuglify public sealed class NuglifySourceMapHandler { private readonly ISmidgeFileSystem _fileSystem; + private readonly ILogger _logger; - public NuglifySourceMapHandler(ISmidgeFileSystem fileSystem) + public NuglifySourceMapHandler(ISmidgeFileSystem fileSystem, ILogger logger) { _fileSystem = fileSystem; + _logger = logger; } public IResult SourceMap(BundleRequestModel bundle) @@ -27,7 +30,13 @@ public IResult SourceMap(BundleRequestModel bundle) return Results.NotFound(); } - var sourceMapFile = _fileSystem.CacheFileSystem.GetRequiredFileInfo(bundle.GetSourceMapFilePath()); + // Look up the source map without throwing. A source map is only produced for bundles that were + // actually minified (e.g. not for files already named *.min.*), and the browser typically requests + // it lazily (when dev tools are opened) which can be well after the bundle was created. In all of + // those cases the map may legitimately be absent, so we must return a 404 rather than letting the + // file system throw a FileNotFoundException that surfaces as an unhandled 500. See issues #199 / #185. + var sourceMapFilePath = bundle.GetSourceMapFilePath(); + var sourceMapFile = _fileSystem.CacheFileSystem.GetFileInfo(sourceMapFilePath); if (sourceMapFile.Exists) { @@ -43,6 +52,7 @@ public IResult SourceMap(BundleRequestModel bundle) } } + _logger.LogDebug("No source map exists for bundle {Bundle} at cache path {SourceMapPath}", bundle.FileKey, sourceMapFilePath); return Results.NotFound(); } } From 41140692880b46d09320a77010dc938e357eb431 Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 12:05:16 -0600 Subject: [PATCH 04/12] Mark request handlers and endpoint filters internal These types are implementation details invoked from the minimal API endpoints wired up in UseSmidge; they were never intended to be part of the public API surface. Marking them internal avoids committing to supporting them as public APIs in Smidge 5. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Smidge/Controllers/SmidgeEndpointFilters.cs | 8 ++++---- src/Smidge/Controllers/SmidgeRequestHandler.cs | 2 +- src/Smidge/Nuglify/NuglifySourceMapHandler.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Smidge/Controllers/SmidgeEndpointFilters.cs b/src/Smidge/Controllers/SmidgeEndpointFilters.cs index e51da9a..97c9630 100644 --- a/src/Smidge/Controllers/SmidgeEndpointFilters.cs +++ b/src/Smidge/Controllers/SmidgeEndpointFilters.cs @@ -16,7 +16,7 @@ namespace Smidge.Controllers /// This is the inner-most endpoint filter so that its short-circuit behaviour is equivalent to the /// previous MVC action filter that had the highest Order. /// - public sealed class CompositeFileCacheEndpointFilter : IEndpointFilter + internal sealed class CompositeFileCacheEndpointFilter : IEndpointFilter { private readonly ISmidgeFileSystem _fileSystem; @@ -71,7 +71,7 @@ internal static bool TryGetCachedCompositeFileResult(ISmidgeFileSystem fileSyste /// /// Checks the request headers to see if the response has been modified, if it has not a 304 is returned and the request is short circuited /// - public sealed class CheckNotModifiedEndpointFilter : IEndpointFilter + internal sealed class CheckNotModifiedEndpointFilter : IEndpointFilter { private readonly IHasher _hasher; @@ -106,7 +106,7 @@ public async ValueTask InvokeAsync(EndpointFilterInvocationContext conte /// /// Adds the correct caching expiry headers when the request is not in debug /// - public sealed class AddExpiryHeadersEndpointFilter : IEndpointFilter + internal sealed class AddExpiryHeadersEndpointFilter : IEndpointFilter { private readonly IHasher _hasher; private readonly IBundleManager _bundleManager; @@ -166,7 +166,7 @@ public async ValueTask InvokeAsync(EndpointFilterInvocationContext conte /// /// Adds the compression headers /// - public sealed class AddCompressionHeaderEndpointFilter : IEndpointFilter + internal sealed class AddCompressionHeaderEndpointFilter : IEndpointFilter { private readonly IRequestHelper _requestHelper; private readonly IBundleManager _bundleManager; diff --git a/src/Smidge/Controllers/SmidgeRequestHandler.cs b/src/Smidge/Controllers/SmidgeRequestHandler.cs index 2a4409b..9ac52dd 100644 --- a/src/Smidge/Controllers/SmidgeRequestHandler.cs +++ b/src/Smidge/Controllers/SmidgeRequestHandler.cs @@ -24,7 +24,7 @@ namespace Smidge.Controllers /// This was previously an MVC controller. For Smidge 5 it is a lightweight POCO handler invoked directly /// from minimal API endpoints, so Smidge no longer requires MVC. /// - public sealed class SmidgeRequestHandler + internal sealed class SmidgeRequestHandler { private static readonly ConcurrentDictionary s_locks = new ConcurrentDictionary(); diff --git a/src/Smidge/Nuglify/NuglifySourceMapHandler.cs b/src/Smidge/Nuglify/NuglifySourceMapHandler.cs index cb05d9a..526f416 100644 --- a/src/Smidge/Nuglify/NuglifySourceMapHandler.cs +++ b/src/Smidge/Nuglify/NuglifySourceMapHandler.cs @@ -12,7 +12,7 @@ namespace Smidge.Nuglify /// This was previously an MVC controller. For Smidge 5 it is a lightweight POCO handler invoked directly /// from a minimal API endpoint. /// - public sealed class NuglifySourceMapHandler + internal sealed class NuglifySourceMapHandler { private readonly ISmidgeFileSystem _fileSystem; private readonly ILogger _logger; From 5ba98c49246605dbaff3dbfaec10d3a85b0e7ab4 Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 12:05:23 -0600 Subject: [PATCH 05/12] Add unit tests for cache file system lookup contract Covers the new non-throwing GetFileInfo alongside the throwing GetRequiredFileInfo for both the in-memory and physical cache file systems, locking in the behaviour the graceful 404 fix relies on. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/Smidge.Tests/CacheFileSystemTests.cs | 81 +++++++++++++++++++++++ test/Smidge.Tests/Smidge.Tests.csproj | 1 + 2 files changed, 82 insertions(+) create mode 100644 test/Smidge.Tests/CacheFileSystemTests.cs diff --git a/test/Smidge.Tests/CacheFileSystemTests.cs b/test/Smidge.Tests/CacheFileSystemTests.cs new file mode 100644 index 0000000..e9ec938 --- /dev/null +++ b/test/Smidge.Tests/CacheFileSystemTests.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using Microsoft.Extensions.FileProviders; +using Smidge.Cache; +using Smidge.Hashing; +using Smidge.InMemory; +using Xunit; + +namespace Smidge.Tests +{ + /// + /// Tests the throwing vs non-throwing lookup contract on the cache file systems. The non-throwing + /// is what allows request handlers to return a graceful + /// 404 instead of an unhandled 500 for missing/stale/spoofed files (issues #199, #185). + /// + public class CacheFileSystemTests + { + [Fact] + public void MemoryCache_GetFileInfo_Missing_Does_Not_Throw() + { + var fs = new MemoryCacheFileSystem(new Crc32Hasher()); + + IFileInfo result = fs.GetFileInfo("does/not/exist.js"); + + Assert.NotNull(result); + Assert.False(result.Exists); + } + + [Fact] + public void MemoryCache_GetRequiredFileInfo_Missing_Throws() + { + var fs = new MemoryCacheFileSystem(new Crc32Hasher()); + + Assert.Throws(() => fs.GetRequiredFileInfo("does/not/exist.js")); + } + + [Fact] + public void PhysicalCache_GetFileInfo_Missing_Does_Not_Throw() + { + using var temp = new TempFolder(); + var fs = new PhysicalFileCacheFileSystem(new PhysicalFileProvider(temp.Path), new Crc32Hasher()); + + IFileInfo result = fs.GetFileInfo("does-not-exist.css"); + + Assert.NotNull(result); + Assert.False(result.Exists); + } + + [Fact] + public void PhysicalCache_GetRequiredFileInfo_Missing_Throws() + { + using var temp = new TempFolder(); + var fs = new PhysicalFileCacheFileSystem(new PhysicalFileProvider(temp.Path), new Crc32Hasher()); + + Assert.Throws(() => fs.GetRequiredFileInfo("does-not-exist.css")); + } + + private sealed class TempFolder : IDisposable + { + public TempFolder() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "smidge-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, true); + } + catch + { + // best effort cleanup + } + } + } + } +} diff --git a/test/Smidge.Tests/Smidge.Tests.csproj b/test/Smidge.Tests/Smidge.Tests.csproj index ac97022..91c6906 100644 --- a/test/Smidge.Tests/Smidge.Tests.csproj +++ b/test/Smidge.Tests/Smidge.Tests.csproj @@ -10,6 +10,7 @@ + From 0fd6cd3dcf6e833c27d66ba96200391d33dfbf7f Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 12:05:31 -0600 Subject: [PATCH 06/12] Add self-hosted Kestrel integration tests Adds a Smidge.Integration.Tests project that self-hosts Smidge on Kestrel and exercises the same scenarios covered manually by the Smidge.Web sample views: production and debug bundles, dynamic composite files, source maps (served and gracefully 404'd), spoofed composite requests, empty bundles, conditional (304) requests and gzip compression. The whole suite runs twice via IClassFixture, once against the in-memory cache and once against the physical cache, to guard both code paths including the graceful 404 handling for missing cached/source-map files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Smidge.sln | 59 ++++ .../Smidge.Integration.Tests.csproj | 36 +++ .../SmidgeCacheEndpointTests.cs | 41 +++ .../SmidgeEndpointTests.cs | 278 ++++++++++++++++++ .../Smidge.Integration.Tests/SmidgeTestApp.cs | 169 +++++++++++ .../wwwroot/Css/Bundle1/a1.css | 5 + .../wwwroot/Css/Bundle1/a2.css | 5 + .../wwwroot/Css/Folder/f1.css | 5 + .../wwwroot/Css/notFoundMap.min.css | 1 + .../wwwroot/Js/Bundle1/a1.js | 5 + .../wwwroot/Js/Bundle1/a2.js | 8 + .../wwwroot/Js/Bundle1/a3.min.js | 1 + .../wwwroot/Js/Folder/f1.js | 8 + .../wwwroot/Js/Folder/f2.js | 7 + 14 files changed, 628 insertions(+) create mode 100644 test/Smidge.Integration.Tests/Smidge.Integration.Tests.csproj create mode 100644 test/Smidge.Integration.Tests/SmidgeCacheEndpointTests.cs create mode 100644 test/Smidge.Integration.Tests/SmidgeEndpointTests.cs create mode 100644 test/Smidge.Integration.Tests/SmidgeTestApp.cs create mode 100644 test/Smidge.Integration.Tests/wwwroot/Css/Bundle1/a1.css create mode 100644 test/Smidge.Integration.Tests/wwwroot/Css/Bundle1/a2.css create mode 100644 test/Smidge.Integration.Tests/wwwroot/Css/Folder/f1.css create mode 100644 test/Smidge.Integration.Tests/wwwroot/Css/notFoundMap.min.css create mode 100644 test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a1.js create mode 100644 test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a2.js create mode 100644 test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a3.min.js create mode 100644 test/Smidge.Integration.Tests/wwwroot/Js/Folder/f1.js create mode 100644 test/Smidge.Integration.Tests/wwwroot/Js/Folder/f2.js diff --git a/Smidge.sln b/Smidge.sln index eb6c750..5446261 100644 --- a/Smidge.sln +++ b/Smidge.sln @@ -30,32 +30,90 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Smidge.InMemory", "src\Smid EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Smidge.Core", "src\Smidge.Core\Smidge.Core.csproj", "{B19C5049-69BA-4581-918F-6C2B4961E326}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Smidge.Integration.Tests", "test\Smidge.Integration.Tests\Smidge.Integration.Tests.csproj", "{AE340C50-9C14-4AF7-8EED-93C793C7BD82}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|x64.ActiveCfg = Debug|Any CPU + {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|x64.Build.0 = Debug|Any CPU + {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|x86.ActiveCfg = Debug|Any CPU + {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|x86.Build.0 = Debug|Any CPU {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|Any CPU.ActiveCfg = Release|Any CPU {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|Any CPU.Build.0 = Release|Any CPU + {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|x64.ActiveCfg = Release|Any CPU + {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|x64.Build.0 = Release|Any CPU + {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|x86.ActiveCfg = Release|Any CPU + {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|x86.Build.0 = Release|Any CPU {8C286364-9589-4B52-818E-657A9B12C172}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8C286364-9589-4B52-818E-657A9B12C172}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C286364-9589-4B52-818E-657A9B12C172}.Debug|x64.ActiveCfg = Debug|Any CPU + {8C286364-9589-4B52-818E-657A9B12C172}.Debug|x64.Build.0 = Debug|Any CPU + {8C286364-9589-4B52-818E-657A9B12C172}.Debug|x86.ActiveCfg = Debug|Any CPU + {8C286364-9589-4B52-818E-657A9B12C172}.Debug|x86.Build.0 = Debug|Any CPU {8C286364-9589-4B52-818E-657A9B12C172}.Release|Any CPU.ActiveCfg = Release|Any CPU {8C286364-9589-4B52-818E-657A9B12C172}.Release|Any CPU.Build.0 = Release|Any CPU + {8C286364-9589-4B52-818E-657A9B12C172}.Release|x64.ActiveCfg = Release|Any CPU + {8C286364-9589-4B52-818E-657A9B12C172}.Release|x64.Build.0 = Release|Any CPU + {8C286364-9589-4B52-818E-657A9B12C172}.Release|x86.ActiveCfg = Release|Any CPU + {8C286364-9589-4B52-818E-657A9B12C172}.Release|x86.Build.0 = Release|Any CPU {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|x64.ActiveCfg = Debug|Any CPU + {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|x64.Build.0 = Debug|Any CPU + {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|x86.ActiveCfg = Debug|Any CPU + {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|x86.Build.0 = Debug|Any CPU {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|Any CPU.ActiveCfg = Release|Any CPU {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|Any CPU.Build.0 = Release|Any CPU + {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|x64.ActiveCfg = Release|Any CPU + {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|x64.Build.0 = Release|Any CPU + {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|x86.ActiveCfg = Release|Any CPU + {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|x86.Build.0 = Release|Any CPU {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|x64.Build.0 = Debug|Any CPU + {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|x86.Build.0 = Debug|Any CPU {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|Any CPU.ActiveCfg = Release|Any CPU {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|Any CPU.Build.0 = Release|Any CPU + {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|x64.ActiveCfg = Release|Any CPU + {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|x64.Build.0 = Release|Any CPU + {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|x86.ActiveCfg = Release|Any CPU + {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|x86.Build.0 = Release|Any CPU {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|x64.ActiveCfg = Debug|Any CPU + {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|x64.Build.0 = Debug|Any CPU + {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|x86.ActiveCfg = Debug|Any CPU + {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|x86.Build.0 = Debug|Any CPU {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|Any CPU.ActiveCfg = Release|Any CPU {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|Any CPU.Build.0 = Release|Any CPU + {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|x64.ActiveCfg = Release|Any CPU + {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|x64.Build.0 = Release|Any CPU + {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|x86.ActiveCfg = Release|Any CPU + {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|x86.Build.0 = Release|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|x64.Build.0 = Debug|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|x86.Build.0 = Debug|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|Any CPU.Build.0 = Release|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|x64.ActiveCfg = Release|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|x64.Build.0 = Release|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|x86.ActiveCfg = Release|Any CPU + {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -66,6 +124,7 @@ Global {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7} = {A5154E3B-762A-4720-A947-C7A3EA25835A} {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF} = {A5154E3B-762A-4720-A947-C7A3EA25835A} {B19C5049-69BA-4581-918F-6C2B4961E326} = {A5154E3B-762A-4720-A947-C7A3EA25835A} + {AE340C50-9C14-4AF7-8EED-93C793C7BD82} = {46C13455-622B-44B6-A129-F96EADAD24C8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A0EB5714-BE56-4F17-B40A-8E8FADFDF503} diff --git a/test/Smidge.Integration.Tests/Smidge.Integration.Tests.csproj b/test/Smidge.Integration.Tests/Smidge.Integration.Tests.csproj new file mode 100644 index 0000000..2f84a7a --- /dev/null +++ b/test/Smidge.Integration.Tests/Smidge.Integration.Tests.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + Smidge.Integration.Tests + Smidge.Integration.Tests + false + true + true + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + PreserveNewest + + + + diff --git a/test/Smidge.Integration.Tests/SmidgeCacheEndpointTests.cs b/test/Smidge.Integration.Tests/SmidgeCacheEndpointTests.cs new file mode 100644 index 0000000..89cff2a --- /dev/null +++ b/test/Smidge.Integration.Tests/SmidgeCacheEndpointTests.cs @@ -0,0 +1,41 @@ +using System.Threading.Tasks; +using Xunit; + +namespace Smidge.Integration.Tests +{ + /// + /// Starts a single shared by all tests in a class. + /// + public abstract class SmidgeAppFixture : IAsyncLifetime + { + private readonly bool _inMemory; + + protected SmidgeAppFixture(bool inMemory) => _inMemory = inMemory; + + public SmidgeTestApp App { get; private set; } = null!; + + public async Task InitializeAsync() => App = await SmidgeTestApp.StartAsync(_inMemory); + + public async Task DisposeAsync() => await App.DisposeAsync(); + } + + public sealed class InMemoryCacheAppFixture : SmidgeAppFixture + { + public InMemoryCacheAppFixture() : base(inMemory: true) { } + } + + public sealed class PhysicalCacheAppFixture : SmidgeAppFixture + { + public PhysicalCacheAppFixture() : base(inMemory: false) { } + } + + public sealed class InMemoryCacheEndpointTests : SmidgeEndpointTestsBase, IClassFixture + { + public InMemoryCacheEndpointTests(InMemoryCacheAppFixture fixture) : base(fixture) { } + } + + public sealed class PhysicalCacheEndpointTests : SmidgeEndpointTestsBase, IClassFixture + { + public PhysicalCacheEndpointTests(PhysicalCacheAppFixture fixture) : base(fixture) { } + } +} diff --git a/test/Smidge.Integration.Tests/SmidgeEndpointTests.cs b/test/Smidge.Integration.Tests/SmidgeEndpointTests.cs new file mode 100644 index 0000000..6eea32f --- /dev/null +++ b/test/Smidge.Integration.Tests/SmidgeEndpointTests.cs @@ -0,0 +1,278 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Xunit; + +namespace Smidge.Integration.Tests +{ + /// + /// End to end tests exercising the real Smidge request pipeline over a self-hosted Kestrel server. These mirror + /// the scenarios that are normally verified by hand with the Smidge.Web sample (Views/Home): named bundles in + /// production and debug, dynamically required composite files, source maps, conditional requests, compression, + /// and the graceful 404 handling for missing/stale/spoofed files. + /// + /// The whole suite runs twice: once against the in-memory cache and once against the physical file cache. + /// + public abstract class SmidgeEndpointTestsBase + { + private readonly SmidgeTestApp _app; + + protected SmidgeEndpointTestsBase(SmidgeAppFixture fixture) => _app = fixture.App; + + [Fact] + public async Task Js_Bundle_Production_Returns_Minified_Combined_With_Caching_Headers() + { + using var client = _app.CreateClient(); + + var urls = await GetUrlsAsync(client, "/urls/js/test-bundle-1"); + + // A production bundle collapses to a single combined URL + Assert.Single(urls); + + using var response = await client.GetAsync(urls[0]); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("javascript", response.Content.Headers.ContentType!.MediaType); + + var body = await response.Content.ReadAsStringAsync(); + Assert.NotEmpty(body); + // Minification strips the source comments + Assert.DoesNotContain("// a1.js", body); + + // Caching headers are applied for production requests + Assert.NotNull(response.Headers.ETag); + Assert.NotNull(response.Headers.CacheControl); + } + + [Fact] + public async Task Js_Bundle_Debug_Returns_Individual_Files() + { + using var client = _app.CreateClient(); + + var urls = await GetUrlsAsync(client, "/urls/js/test-bundle-1?debug=true"); + + // In debug the files are not combined + Assert.True(urls.Length >= 2, $"Expected multiple debug URLs but got {urls.Length}"); + + foreach (var url in urls) + { + using var response = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + } + + [Fact] + public async Task Css_Bundle_Production_Returns_Minified() + { + using var client = _app.CreateClient(); + + var urls = await GetUrlsAsync(client, "/urls/css/test-bundle-css"); + Assert.Single(urls); + + using var response = await client.GetAsync(urls[0]); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("css", response.Content.Headers.ContentType!.MediaType); + + var body = await response.Content.ReadAsStringAsync(); + Assert.NotEmpty(body); + Assert.DoesNotContain("/* a1.css */", body); + } + + [Fact] + public async Task Dynamic_Composite_Js_Returns_Combined_Content() + { + using var client = _app.CreateClient(); + + var urls = await GetUrlsAsync(client, "/urls/dynamic-js"); + Assert.NotEmpty(urls); + Assert.All(urls, u => Assert.Contains("/sc/", u)); + + foreach (var url in urls) + { + using var response = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(await response.Content.ReadAsStringAsync()); + } + } + + [Fact] + public async Task SourceMap_For_Minified_Js_Bundle_Is_Served() + { + using var client = _app.CreateClient(); + + var urls = await GetUrlsAsync(client, "/urls/js/test-bundle-1"); + var bundleUrl = urls.Single(); + + // Fetching the bundle triggers processing and writes the external source map + var js = await client.GetStringAsync(bundleUrl); + + var match = Regex.Match(js, @"sourceMappingURL=(?\S+)"); + Assert.True(match.Success, "Expected an external sourceMappingURL comment in the minified bundle output"); + + var mapUrl = match.Groups["url"].Value; + + using var mapResponse = await client.GetAsync(mapUrl); + Assert.Equal(HttpStatusCode.OK, mapResponse.StatusCode); + Assert.Contains("json", mapResponse.Content.Headers.ContentType!.MediaType); + + var mapBody = await mapResponse.Content.ReadAsStringAsync(); + Assert.Contains("\"version\"", mapBody); + } + + [Fact] + public async Task SourceMap_For_Bundle_Without_Map_Returns_404_Not_500() + { + using var client = _app.CreateClient(); + + // This bundle is built from an already-minified .min.css file, so no source map is ever generated. + // Build the bundle first, then request its (non-existent) source map. Prior to the fix this threw a + // FileNotFoundException that surfaced as a 500 - a repeatable DoS vector (issues #199 / #185). + var urls = await GetUrlsAsync(client, "/urls/css/notfound-map-css-bundle"); + var bundleUrl = urls.Single(); + (await client.GetAsync(bundleUrl)).Dispose(); + + var mapUrl = ToSourceMapUrl(bundleUrl); + + using var response = await client.GetAsync(mapUrl); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Spoofed_Composite_File_Returns_404_Not_500() + { + using var client = _app.CreateClient(); + + // Get a real composite URL (with the current, valid cache buster) then swap the file hashes for a bogus + // one so the referenced cache file does not exist. This is exactly the DoS scenario from issue #199: + // it must be a graceful 404, not an unhandled 500. + var urls = await GetUrlsAsync(client, "/urls/dynamic-js"); + var compositeUrl = urls.First(); + + var spoofed = SpoofCompositeFileName(compositeUrl); + + using var response = await client.GetAsync(spoofed); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task No_Files_Bundle_Returns_404() + { + using var client = _app.CreateClient(); + + var urls = await GetUrlsAsync(client, "/urls/js/no-files"); + + // Either no URL is produced, or the produced URL yields a 404 (there is nothing to serve) + foreach (var url in urls) + { + using var response = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + } + + [Fact] + public async Task Conditional_Request_With_Matching_ETag_Returns_304() + { + using var client = _app.CreateClient(); + + var urls = await GetUrlsAsync(client, "/urls/js/test-bundle-1"); + var bundleUrl = urls.Single(); + + using var first = await client.GetAsync(bundleUrl); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + var etag = first.Headers.ETag; + Assert.NotNull(etag); + + using var conditional = new HttpRequestMessage(HttpMethod.Get, bundleUrl); + conditional.Headers.IfNoneMatch.Add(etag); + + using var second = await client.SendAsync(conditional); + Assert.Equal(HttpStatusCode.NotModified, second.StatusCode); + } + + [Fact] + public async Task Compressed_Request_Returns_Gzip_Encoded_Body() + { + using var client = _app.CreateClient(); + + var urls = await GetUrlsAsync(client, "/urls/dynamic-js"); + var compositeUrl = urls.First(); + + using var request = new HttpRequestMessage(HttpMethod.Get, compositeUrl); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); + + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("gzip", response.Content.Headers.ContentEncoding); + + // Body is really gzip and decompresses to non-empty content + var bytes = await response.Content.ReadAsByteArrayAsync(); + using var input = new MemoryStream(bytes); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var reader = new StreamReader(gzip); + var decompressed = await reader.ReadToEndAsync(); + Assert.NotEmpty(decompressed); + } + + [Fact] + public async Task Uncompressed_Request_Has_No_Content_Encoding() + { + using var client = _app.CreateClient(); + + var urls = await GetUrlsAsync(client, "/urls/dynamic-js"); + var compositeUrl = urls.First(); + + using var request = new HttpRequestMessage(HttpMethod.Get, compositeUrl); + request.Headers.AcceptEncoding.Clear(); + + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(response.Content.Headers.ContentEncoding); + } + + private static async Task GetUrlsAsync(HttpClient client, string path) + { + var content = await client.GetStringAsync(path); + return content + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .ToArray(); + } + + /// + /// Turns a bundle URL like /sb/name.css.vABC into its source-map URL /sb/nmap/name.css.vABC. + /// + private static string ToSourceMapUrl(string bundleUrl) + { + var lastSlash = bundleUrl.LastIndexOf('/'); + return bundleUrl.Substring(0, lastSlash) + "/nmap" + bundleUrl.Substring(lastSlash); + } + + /// + /// Replaces the file-hash portion of a composite URL (e.g. /sc/a.b.js.vABC) with a bogus value while + /// preserving the extension and the (valid) cache buster value, producing a request that references a file + /// that does not exist. + /// + private static string SpoofCompositeFileName(string compositeUrl) + { + var lastSlash = compositeUrl.LastIndexOf('/'); + var prefix = compositeUrl.Substring(0, lastSlash + 1); + var segment = compositeUrl.Substring(lastSlash + 1); + + var match = Regex.Match(segment, @"\.(js|css)\.[vd].+$"); + Assert.True(match.Success, $"Unexpected composite URL format: {compositeUrl}"); + + return prefix + "deadbeefdeadbeef" + match.Value; + } + } +} diff --git a/test/Smidge.Integration.Tests/SmidgeTestApp.cs b/test/Smidge.Integration.Tests/SmidgeTestApp.cs new file mode 100644 index 0000000..9a9a709 --- /dev/null +++ b/test/Smidge.Integration.Tests/SmidgeTestApp.cs @@ -0,0 +1,169 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Smidge; +using Smidge.Cache; +using Smidge.InMemory; +using Smidge.Models; +using Smidge.Options; + +namespace Smidge.Integration.Tests +{ + /// + /// Spins up a real, self-hosted Kestrel server running Smidge end to end. This mirrors the manual testing + /// that is normally done with the Smidge.Web sample project (the Views/Home scenarios): a set of bundles are + /// configured and small helper endpoints render the bundle URLs via exactly like a + /// Razor view would. Tests then make real HTTP requests to those URLs and assert on the responses. + /// + public sealed class SmidgeTestApp : IAsyncDisposable + { + private readonly IHost _host; + + private SmidgeTestApp(IHost host, string baseAddress) + { + _host = host; + BaseAddress = baseAddress; + } + + public string BaseAddress { get; } + + /// + /// A client that leaves compression untouched so tests can assert on Content-Encoding and decompress manually. + /// + public HttpClient CreateClient() + => new HttpClient(new HttpClientHandler { AutomaticDecompression = System.Net.DecompressionMethods.None }) + { + BaseAddress = new Uri(BaseAddress) + }; + + public static async Task StartAsync(bool inMemory) + { + var contentRoot = AppContext.BaseDirectory; + var webRoot = Path.Combine(contentRoot, "wwwroot"); + + var builder = Host.CreateDefaultBuilder() + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseKestrel(); + webBuilder.UseContentRoot(contentRoot); + webBuilder.UseWebRoot(webRoot); + webBuilder.UseUrls("http://127.0.0.1:0"); + webBuilder.ConfigureServices(services => + { + services.AddRouting(); + services.AddSmidge(new ConfigurationBuilder().Build()); + + if (inMemory) + { + services.AddSmidgeInMemory(); + } + + services.Configure(options => + { + // A cache buster that is stable for the lifetime of the process, matching the Smidge.Web sample. + options.DefaultBundleOptions.DebugOptions.SetCacheBusterType(); + options.DefaultBundleOptions.ProductionOptions.SetCacheBusterType(); + }); + }); + webBuilder.Configure(app => + { + app.UseStaticFiles(); + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + // Render the URLs Smidge would emit for a named JS bundle, newline separated. + endpoints.MapGet("/urls/js/{bundle}", async (HttpContext ctx, string bundle) => + { + var smidge = ctx.RequestServices.GetRequiredService(); + var debug = IsDebug(ctx); + var urls = await smidge.GenerateJsUrlsAsync(bundle, debug); + return UrlResult(urls); + }); + + // Render the URLs Smidge would emit for a named CSS bundle, newline separated. + endpoints.MapGet("/urls/css/{bundle}", async (HttpContext ctx, string bundle) => + { + var smidge = ctx.RequestServices.GetRequiredService(); + var debug = IsDebug(ctx); + var urls = await smidge.GenerateCssUrlsAsync(bundle, debug); + return UrlResult(urls); + }); + + // Dynamically require a folder of JS files (no named bundle) which produces composite URLs. + endpoints.MapGet("/urls/dynamic-js", async (HttpContext ctx) => + { + var smidge = ctx.RequestServices.GetRequiredService(); + smidge.RequiresJs("~/Js/Folder/*.js"); + var urls = await smidge.GenerateJsUrlsAsync(debug: IsDebug(ctx)); + return UrlResult(urls); + }); + + // Dynamically require a folder of CSS files (no named bundle) which produces composite URLs. + endpoints.MapGet("/urls/dynamic-css", async (HttpContext ctx) => + { + var smidge = ctx.RequestServices.GetRequiredService(); + smidge.RequiresCss("~/Css/Folder/*.css"); + var urls = await smidge.GenerateCssUrlsAsync(debug: IsDebug(ctx)); + return UrlResult(urls); + }); + }); + + app.UseSmidge(bundles => + { + // JS bundle with a couple of real files plus one already-minified file (min removed by convention). + bundles.Create("test-bundle-1", + new JavaScriptFile("~/Js/Bundle1/a1.js"), + new JavaScriptFile("~/Js/Bundle1/a2.js"), + new JavaScriptFile("~/Js/Bundle1/a3.min.js")); + + // CSS bundle. + bundles.CreateCss("test-bundle-css", + "~/Css/Bundle1/a1.css", + "~/Css/Bundle1/a2.css"); + + // A bundle whose glob matches nothing. + bundles.CreateJs("no-files", "~/Js/not-found/*.js"); + + // A CSS bundle built from an already-minified file. No source map is ever generated for it, + // so requesting its /nmap URL must return 404 (previously threw a 500). See issues #199 / #185. + bundles.CreateCss("notfound-map-css-bundle", "~/Css/notFoundMap.min.css"); + }); + }); + }); + + var host = await builder.StartAsync(); + + var address = host.Services + .GetRequiredService() + .Features + .Get()! + .Addresses + .First(); + + return new SmidgeTestApp(host, address); + } + + private static bool IsDebug(HttpContext ctx) + => string.Equals(ctx.Request.Query["debug"], "true", StringComparison.OrdinalIgnoreCase); + + private static IResult UrlResult(System.Collections.Generic.IEnumerable urls) + => Results.Text(string.Join("\n", urls), "text/plain"); + + public async ValueTask DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + } +} diff --git a/test/Smidge.Integration.Tests/wwwroot/Css/Bundle1/a1.css b/test/Smidge.Integration.Tests/wwwroot/Css/Bundle1/a1.css new file mode 100644 index 0000000..89e00a8 --- /dev/null +++ b/test/Smidge.Integration.Tests/wwwroot/Css/Bundle1/a1.css @@ -0,0 +1,5 @@ +/* a1.css */ +body { + background-color: #ffffff; + color: #222222; +} diff --git a/test/Smidge.Integration.Tests/wwwroot/Css/Bundle1/a2.css b/test/Smidge.Integration.Tests/wwwroot/Css/Bundle1/a2.css new file mode 100644 index 0000000..840a779 --- /dev/null +++ b/test/Smidge.Integration.Tests/wwwroot/Css/Bundle1/a2.css @@ -0,0 +1,5 @@ +/* a2.css */ +.smidge-heading { + font-size: 20px; + margin: 0 0 10px 0; +} diff --git a/test/Smidge.Integration.Tests/wwwroot/Css/Folder/f1.css b/test/Smidge.Integration.Tests/wwwroot/Css/Folder/f1.css new file mode 100644 index 0000000..9592c0d --- /dev/null +++ b/test/Smidge.Integration.Tests/wwwroot/Css/Folder/f1.css @@ -0,0 +1,5 @@ +/* f1.css */ +.smidge-folder { + display: block; + padding: 5px; +} diff --git a/test/Smidge.Integration.Tests/wwwroot/Css/notFoundMap.min.css b/test/Smidge.Integration.Tests/wwwroot/Css/notFoundMap.min.css new file mode 100644 index 0000000..b673fed --- /dev/null +++ b/test/Smidge.Integration.Tests/wwwroot/Css/notFoundMap.min.css @@ -0,0 +1 @@ +.smidge-min{color:red} diff --git a/test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a1.js b/test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a1.js new file mode 100644 index 0000000..eb9f011 --- /dev/null +++ b/test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a1.js @@ -0,0 +1,5 @@ +// a1.js +function smidgeA1(firstName, lastName) { + var fullName = firstName + " " + lastName; + return "Hello, " + fullName + "!"; +} diff --git a/test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a2.js b/test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a2.js new file mode 100644 index 0000000..f2ca590 --- /dev/null +++ b/test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a2.js @@ -0,0 +1,8 @@ +// a2.js +function smidgeA2(numbers) { + var total = 0; + for (var i = 0; i < numbers.length; i++) { + total += numbers[i]; + } + return total; +} diff --git a/test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a3.min.js b/test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a3.min.js new file mode 100644 index 0000000..eb7919e --- /dev/null +++ b/test/Smidge.Integration.Tests/wwwroot/Js/Bundle1/a3.min.js @@ -0,0 +1 @@ +var smidgeA3=function(a,b){return a*b;}; diff --git a/test/Smidge.Integration.Tests/wwwroot/Js/Folder/f1.js b/test/Smidge.Integration.Tests/wwwroot/Js/Folder/f1.js new file mode 100644 index 0000000..a4a9eb8 --- /dev/null +++ b/test/Smidge.Integration.Tests/wwwroot/Js/Folder/f1.js @@ -0,0 +1,8 @@ +// f1.js +var smidgeFolderOne = (function () { + var counter = 0; + return function increment() { + counter = counter + 1; + return counter; + }; +})(); diff --git a/test/Smidge.Integration.Tests/wwwroot/Js/Folder/f2.js b/test/Smidge.Integration.Tests/wwwroot/Js/Folder/f2.js new file mode 100644 index 0000000..bb8a0f1 --- /dev/null +++ b/test/Smidge.Integration.Tests/wwwroot/Js/Folder/f2.js @@ -0,0 +1,7 @@ +// f2.js +var smidgeFolderTwo = function (message) { + if (message) { + return message.toUpperCase(); + } + return ""; +}; From b676d418ec36932fb776088709469dbc3e420c40 Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 12:31:23 -0600 Subject: [PATCH 07/12] Split multi-type files into one type per file Moves the four endpoint filters out of SmidgeEndpointFilters.cs and the integration test fixtures out of SmidgeCacheEndpointTests.cs into individual files, and extracts the nested test helper types (TempFolder, pre-processor stubs) into their own files. No behaviour changes; purely a file layout refactor. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AddCompressionHeaderEndpointFilter.cs | 45 ++++ .../AddExpiryHeadersEndpointFilter.cs | 70 ++++++ .../CheckNotModifiedEndpointFilter.cs | 44 ++++ .../CompositeFileCacheEndpointFilter.cs | 68 ++++++ .../Controllers/SmidgeEndpointFilters.cs | 202 ------------------ .../InMemoryCacheAppFixture.cs | 7 + .../InMemoryCacheEndpointTests.cs | 9 + .../PhysicalCacheAppFixture.cs | 7 + .../PhysicalCacheEndpointTests.cs | 9 + .../SmidgeAppFixture.cs | 21 ++ .../SmidgeCacheEndpointTests.cs | 41 ---- test/Smidge.Tests/CacheFileSystemTests.cs | 23 -- .../Smidge.Tests/PreProcessorPipelineTests.cs | 41 +--- test/Smidge.Tests/ProcessorFooter.cs | 14 ++ test/Smidge.Tests/ProcessorHeader.cs | 14 ++ test/Smidge.Tests/ProcessorHeaderAndFooter.cs | 15 ++ test/Smidge.Tests/TempFolder.cs | 31 +++ 17 files changed, 357 insertions(+), 304 deletions(-) create mode 100644 src/Smidge/Controllers/AddCompressionHeaderEndpointFilter.cs create mode 100644 src/Smidge/Controllers/AddExpiryHeadersEndpointFilter.cs create mode 100644 src/Smidge/Controllers/CheckNotModifiedEndpointFilter.cs create mode 100644 src/Smidge/Controllers/CompositeFileCacheEndpointFilter.cs delete mode 100644 src/Smidge/Controllers/SmidgeEndpointFilters.cs create mode 100644 test/Smidge.Integration.Tests/InMemoryCacheAppFixture.cs create mode 100644 test/Smidge.Integration.Tests/InMemoryCacheEndpointTests.cs create mode 100644 test/Smidge.Integration.Tests/PhysicalCacheAppFixture.cs create mode 100644 test/Smidge.Integration.Tests/PhysicalCacheEndpointTests.cs create mode 100644 test/Smidge.Integration.Tests/SmidgeAppFixture.cs delete mode 100644 test/Smidge.Integration.Tests/SmidgeCacheEndpointTests.cs create mode 100644 test/Smidge.Tests/ProcessorFooter.cs create mode 100644 test/Smidge.Tests/ProcessorHeader.cs create mode 100644 test/Smidge.Tests/ProcessorHeaderAndFooter.cs create mode 100644 test/Smidge.Tests/TempFolder.cs diff --git a/src/Smidge/Controllers/AddCompressionHeaderEndpointFilter.cs b/src/Smidge/Controllers/AddCompressionHeaderEndpointFilter.cs new file mode 100644 index 0000000..5fc8325 --- /dev/null +++ b/src/Smidge/Controllers/AddCompressionHeaderEndpointFilter.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Smidge.Models; + +namespace Smidge.Controllers +{ + /// + /// Adds the compression headers + /// + internal sealed class AddCompressionHeaderEndpointFilter : IEndpointFilter + { + private readonly IRequestHelper _requestHelper; + private readonly IBundleManager _bundleManager; + + public AddCompressionHeaderEndpointFilter(IRequestHelper requestHelper, IBundleManager bundleManager) + { + _requestHelper = requestHelper ?? throw new ArgumentNullException(nameof(requestHelper)); + _bundleManager = bundleManager ?? throw new ArgumentNullException(nameof(bundleManager)); + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var result = await next(context); + + if (context.Arguments.OfType().FirstOrDefault() is RequestModel file && file.IsBundleFound) + { + var enableCompression = true; + + //check if it's a bundle (not composite file) + if (file is BundleRequestModel bundleRequest && _bundleManager.TryGetValue(bundleRequest.FileKey, out var bundle)) + { + var bundleOptions = bundle.GetBundleOptions(_bundleManager, bundleRequest.Debug); + enableCompression = bundleOptions.CompressResult; + } + + if (enableCompression) + context.HttpContext.Response.AddCompressionResponseHeader(_requestHelper.GetClientCompression(context.HttpContext.Request.Headers)); + } + + return result; + } + } +} diff --git a/src/Smidge/Controllers/AddExpiryHeadersEndpointFilter.cs b/src/Smidge/Controllers/AddExpiryHeadersEndpointFilter.cs new file mode 100644 index 0000000..17a7d5e --- /dev/null +++ b/src/Smidge/Controllers/AddExpiryHeadersEndpointFilter.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Smidge.Hashing; +using Smidge.Models; +using Smidge.Options; + +namespace Smidge.Controllers +{ + /// + /// Adds the correct caching expiry headers when the request is not in debug + /// + internal sealed class AddExpiryHeadersEndpointFilter : IEndpointFilter + { + private readonly IHasher _hasher; + private readonly IBundleManager _bundleManager; + + public AddExpiryHeadersEndpointFilter(IHasher hasher, IBundleManager bundleManager) + { + _hasher = hasher ?? throw new ArgumentNullException(nameof(hasher)); + _bundleManager = bundleManager ?? throw new ArgumentNullException(nameof(bundleManager)); + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var result = await next(context); + + if (context.Arguments.OfType().FirstOrDefault() is not RequestModel file || !file.IsBundleFound) + return result; + + var enableETag = true; + var cacheControlMaxAge = 10 * 24; //10 days + + BundleOptions bundleOptions; + + if (_bundleManager.TryGetValue(file.FileKey, out Bundle b)) + { + bundleOptions = b.GetBundleOptions(_bundleManager, file.Debug); + } + else + { + bundleOptions = file.Debug ? _bundleManager.DefaultBundleOptions.DebugOptions : _bundleManager.DefaultBundleOptions.ProductionOptions; + } + + if (bundleOptions != null) + { + enableETag = bundleOptions.CacheControlOptions.EnableETag; + cacheControlMaxAge = bundleOptions.CacheControlOptions.CacheControlMaxAge; + } + + var response = context.HttpContext.Response; + + if (enableETag) + { + var etag = _hasher.Hash(file.FileKey + file.Compression + file.Mime); + response.AddETagResponseHeader(etag); + } + + if (cacheControlMaxAge > 0) + { + response.AddCacheControlResponseHeader(cacheControlMaxAge); + response.AddLastModifiedResponseHeader(file); + response.AddExpiresResponseHeader(cacheControlMaxAge); + } + + return result; + } + } +} diff --git a/src/Smidge/Controllers/CheckNotModifiedEndpointFilter.cs b/src/Smidge/Controllers/CheckNotModifiedEndpointFilter.cs new file mode 100644 index 0000000..d9a6fae --- /dev/null +++ b/src/Smidge/Controllers/CheckNotModifiedEndpointFilter.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Smidge.Hashing; +using Smidge.Models; + +namespace Smidge.Controllers +{ + /// + /// Checks the request headers to see if the response has been modified, if it has not a 304 is returned and the request is short circuited + /// + internal sealed class CheckNotModifiedEndpointFilter : IEndpointFilter + { + private readonly IHasher _hasher; + + public CheckNotModifiedEndpointFilter(IHasher hasher) + => _hasher = hasher ?? throw new ArgumentNullException(nameof(hasher)); + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var result = await next(context); + + if (context.Arguments.OfType().FirstOrDefault() is RequestModel file && file.IsBundleFound) + { + //Don't execute when the request is in Debug + if (file.Debug) + return result; + + var etag = _hasher.Hash(file.FileKey + file.Compression + file.Mime); + + var request = context.HttpContext.Request; + var isDifferent = request.HasETagBeenModified(etag); + var hasChanged = request.HasRequestBeenModifiedSince(file.LastFileWriteTime.ToUniversalTime()); + if (!isDifferent || !hasChanged) + { + return Results.StatusCode(StatusCodes.Status304NotModified); + } + } + + return result; + } + } +} diff --git a/src/Smidge/Controllers/CompositeFileCacheEndpointFilter.cs b/src/Smidge/Controllers/CompositeFileCacheEndpointFilter.cs new file mode 100644 index 0000000..fc49892 --- /dev/null +++ b/src/Smidge/Controllers/CompositeFileCacheEndpointFilter.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Smidge.Models; + +namespace Smidge.Controllers +{ + /// + /// Checks the file system for an already persisted minified, combined, compressed file for the + /// request definition. If there is one it returns that file directly and the endpoint handler does not execute. + /// + /// + /// This is the inner-most endpoint filter so that its short-circuit behaviour is equivalent to the + /// previous MVC action filter that had the highest Order. + /// + internal sealed class CompositeFileCacheEndpointFilter : IEndpointFilter + { + private readonly ISmidgeFileSystem _fileSystem; + + public CompositeFileCacheEndpointFilter(ISmidgeFileSystem fileSystem) + => _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + if (context.Arguments.OfType().FirstOrDefault() is RequestModel file && file.IsBundleFound) + { + var cacheBusterValue = file.ParsedPath.CacheBusterValue; + + if (TryGetCachedCompositeFileResult(_fileSystem, cacheBusterValue, file.FileKey, file.Compression, file.Mime, out IResult result, out DateTime lastWrite)) + { + file.LastFileWriteTime = lastWrite; + + // short-circuit: return the cached file without invoking the handler + return result; + } + } + + return await next(context); + } + + internal static bool TryGetCachedCompositeFileResult(ISmidgeFileSystem fileSystem, string cacheBusterValue, string filesetKey, CompressionType type, string mime, out IResult result, out DateTime lastWriteTime) + { + result = null; + + var cacheFile = fileSystem.CacheFileSystem.GetCachedCompositeFile(cacheBusterValue, type, filesetKey, out _); + if (cacheFile.Exists) + { + lastWriteTime = cacheFile.LastModified.DateTime; + + if (!string.IsNullOrWhiteSpace(cacheFile.PhysicalPath)) + { + //if physical path is available then it's the physical file system, in which case we'll deliver the file with a physical file result + //which uses IHttpSendFileFeature which is a native host option for sending static files + result = Results.File(cacheFile.PhysicalPath, mime); + return true; + } + + //deliver the file via stream + result = Results.Stream(cacheFile.CreateReadStream(), mime); + return true; + } + + lastWriteTime = DateTime.Now; + return false; + } + } +} diff --git a/src/Smidge/Controllers/SmidgeEndpointFilters.cs b/src/Smidge/Controllers/SmidgeEndpointFilters.cs deleted file mode 100644 index 97c9630..0000000 --- a/src/Smidge/Controllers/SmidgeEndpointFilters.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Smidge.Hashing; -using Smidge.Models; -using Smidge.Options; - -namespace Smidge.Controllers -{ - /// - /// Checks the file system for an already persisted minified, combined, compressed file for the - /// request definition. If there is one it returns that file directly and the endpoint handler does not execute. - /// - /// - /// This is the inner-most endpoint filter so that its short-circuit behaviour is equivalent to the - /// previous MVC action filter that had the highest Order. - /// - internal sealed class CompositeFileCacheEndpointFilter : IEndpointFilter - { - private readonly ISmidgeFileSystem _fileSystem; - - public CompositeFileCacheEndpointFilter(ISmidgeFileSystem fileSystem) - => _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); - - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - if (context.Arguments.OfType().FirstOrDefault() is RequestModel file && file.IsBundleFound) - { - var cacheBusterValue = file.ParsedPath.CacheBusterValue; - - if (TryGetCachedCompositeFileResult(_fileSystem, cacheBusterValue, file.FileKey, file.Compression, file.Mime, out IResult result, out DateTime lastWrite)) - { - file.LastFileWriteTime = lastWrite; - - // short-circuit: return the cached file without invoking the handler - return result; - } - } - - return await next(context); - } - - internal static bool TryGetCachedCompositeFileResult(ISmidgeFileSystem fileSystem, string cacheBusterValue, string filesetKey, CompressionType type, string mime, out IResult result, out DateTime lastWriteTime) - { - result = null; - - var cacheFile = fileSystem.CacheFileSystem.GetCachedCompositeFile(cacheBusterValue, type, filesetKey, out _); - if (cacheFile.Exists) - { - lastWriteTime = cacheFile.LastModified.DateTime; - - if (!string.IsNullOrWhiteSpace(cacheFile.PhysicalPath)) - { - //if physical path is available then it's the physical file system, in which case we'll deliver the file with a physical file result - //which uses IHttpSendFileFeature which is a native host option for sending static files - result = Results.File(cacheFile.PhysicalPath, mime); - return true; - } - - //deliver the file via stream - result = Results.Stream(cacheFile.CreateReadStream(), mime); - return true; - } - - lastWriteTime = DateTime.Now; - return false; - } - } - - /// - /// Checks the request headers to see if the response has been modified, if it has not a 304 is returned and the request is short circuited - /// - internal sealed class CheckNotModifiedEndpointFilter : IEndpointFilter - { - private readonly IHasher _hasher; - - public CheckNotModifiedEndpointFilter(IHasher hasher) - => _hasher = hasher ?? throw new ArgumentNullException(nameof(hasher)); - - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - var result = await next(context); - - if (context.Arguments.OfType().FirstOrDefault() is RequestModel file && file.IsBundleFound) - { - //Don't execute when the request is in Debug - if (file.Debug) - return result; - - var etag = _hasher.Hash(file.FileKey + file.Compression + file.Mime); - - var request = context.HttpContext.Request; - var isDifferent = request.HasETagBeenModified(etag); - var hasChanged = request.HasRequestBeenModifiedSince(file.LastFileWriteTime.ToUniversalTime()); - if (!isDifferent || !hasChanged) - { - return Results.StatusCode(StatusCodes.Status304NotModified); - } - } - - return result; - } - } - - /// - /// Adds the correct caching expiry headers when the request is not in debug - /// - internal sealed class AddExpiryHeadersEndpointFilter : IEndpointFilter - { - private readonly IHasher _hasher; - private readonly IBundleManager _bundleManager; - - public AddExpiryHeadersEndpointFilter(IHasher hasher, IBundleManager bundleManager) - { - _hasher = hasher ?? throw new ArgumentNullException(nameof(hasher)); - _bundleManager = bundleManager ?? throw new ArgumentNullException(nameof(bundleManager)); - } - - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - var result = await next(context); - - if (context.Arguments.OfType().FirstOrDefault() is not RequestModel file || !file.IsBundleFound) - return result; - - var enableETag = true; - var cacheControlMaxAge = 10 * 24; //10 days - - BundleOptions bundleOptions; - - if (_bundleManager.TryGetValue(file.FileKey, out Bundle b)) - { - bundleOptions = b.GetBundleOptions(_bundleManager, file.Debug); - } - else - { - bundleOptions = file.Debug ? _bundleManager.DefaultBundleOptions.DebugOptions : _bundleManager.DefaultBundleOptions.ProductionOptions; - } - - if (bundleOptions != null) - { - enableETag = bundleOptions.CacheControlOptions.EnableETag; - cacheControlMaxAge = bundleOptions.CacheControlOptions.CacheControlMaxAge; - } - - var response = context.HttpContext.Response; - - if (enableETag) - { - var etag = _hasher.Hash(file.FileKey + file.Compression + file.Mime); - response.AddETagResponseHeader(etag); - } - - if (cacheControlMaxAge > 0) - { - response.AddCacheControlResponseHeader(cacheControlMaxAge); - response.AddLastModifiedResponseHeader(file); - response.AddExpiresResponseHeader(cacheControlMaxAge); - } - - return result; - } - } - - /// - /// Adds the compression headers - /// - internal sealed class AddCompressionHeaderEndpointFilter : IEndpointFilter - { - private readonly IRequestHelper _requestHelper; - private readonly IBundleManager _bundleManager; - - public AddCompressionHeaderEndpointFilter(IRequestHelper requestHelper, IBundleManager bundleManager) - { - _requestHelper = requestHelper ?? throw new ArgumentNullException(nameof(requestHelper)); - _bundleManager = bundleManager ?? throw new ArgumentNullException(nameof(bundleManager)); - } - - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - var result = await next(context); - - if (context.Arguments.OfType().FirstOrDefault() is RequestModel file && file.IsBundleFound) - { - var enableCompression = true; - - //check if it's a bundle (not composite file) - if (file is BundleRequestModel bundleRequest && _bundleManager.TryGetValue(bundleRequest.FileKey, out var bundle)) - { - var bundleOptions = bundle.GetBundleOptions(_bundleManager, bundleRequest.Debug); - enableCompression = bundleOptions.CompressResult; - } - - if (enableCompression) - context.HttpContext.Response.AddCompressionResponseHeader(_requestHelper.GetClientCompression(context.HttpContext.Request.Headers)); - } - - return result; - } - } -} diff --git a/test/Smidge.Integration.Tests/InMemoryCacheAppFixture.cs b/test/Smidge.Integration.Tests/InMemoryCacheAppFixture.cs new file mode 100644 index 0000000..6cb6111 --- /dev/null +++ b/test/Smidge.Integration.Tests/InMemoryCacheAppFixture.cs @@ -0,0 +1,7 @@ +namespace Smidge.Integration.Tests +{ + public sealed class InMemoryCacheAppFixture : SmidgeAppFixture + { + public InMemoryCacheAppFixture() : base(inMemory: true) { } + } +} diff --git a/test/Smidge.Integration.Tests/InMemoryCacheEndpointTests.cs b/test/Smidge.Integration.Tests/InMemoryCacheEndpointTests.cs new file mode 100644 index 0000000..40147d2 --- /dev/null +++ b/test/Smidge.Integration.Tests/InMemoryCacheEndpointTests.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace Smidge.Integration.Tests +{ + public sealed class InMemoryCacheEndpointTests : SmidgeEndpointTestsBase, IClassFixture + { + public InMemoryCacheEndpointTests(InMemoryCacheAppFixture fixture) : base(fixture) { } + } +} diff --git a/test/Smidge.Integration.Tests/PhysicalCacheAppFixture.cs b/test/Smidge.Integration.Tests/PhysicalCacheAppFixture.cs new file mode 100644 index 0000000..c3fa99d --- /dev/null +++ b/test/Smidge.Integration.Tests/PhysicalCacheAppFixture.cs @@ -0,0 +1,7 @@ +namespace Smidge.Integration.Tests +{ + public sealed class PhysicalCacheAppFixture : SmidgeAppFixture + { + public PhysicalCacheAppFixture() : base(inMemory: false) { } + } +} diff --git a/test/Smidge.Integration.Tests/PhysicalCacheEndpointTests.cs b/test/Smidge.Integration.Tests/PhysicalCacheEndpointTests.cs new file mode 100644 index 0000000..010e7a6 --- /dev/null +++ b/test/Smidge.Integration.Tests/PhysicalCacheEndpointTests.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace Smidge.Integration.Tests +{ + public sealed class PhysicalCacheEndpointTests : SmidgeEndpointTestsBase, IClassFixture + { + public PhysicalCacheEndpointTests(PhysicalCacheAppFixture fixture) : base(fixture) { } + } +} diff --git a/test/Smidge.Integration.Tests/SmidgeAppFixture.cs b/test/Smidge.Integration.Tests/SmidgeAppFixture.cs new file mode 100644 index 0000000..f24cedb --- /dev/null +++ b/test/Smidge.Integration.Tests/SmidgeAppFixture.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Xunit; + +namespace Smidge.Integration.Tests +{ + /// + /// Starts a single shared by all tests in a class. + /// + public abstract class SmidgeAppFixture : IAsyncLifetime + { + private readonly bool _inMemory; + + protected SmidgeAppFixture(bool inMemory) => _inMemory = inMemory; + + public SmidgeTestApp App { get; private set; } = null!; + + public async Task InitializeAsync() => App = await SmidgeTestApp.StartAsync(_inMemory); + + public async Task DisposeAsync() => await App.DisposeAsync(); + } +} diff --git a/test/Smidge.Integration.Tests/SmidgeCacheEndpointTests.cs b/test/Smidge.Integration.Tests/SmidgeCacheEndpointTests.cs deleted file mode 100644 index 89cff2a..0000000 --- a/test/Smidge.Integration.Tests/SmidgeCacheEndpointTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Threading.Tasks; -using Xunit; - -namespace Smidge.Integration.Tests -{ - /// - /// Starts a single shared by all tests in a class. - /// - public abstract class SmidgeAppFixture : IAsyncLifetime - { - private readonly bool _inMemory; - - protected SmidgeAppFixture(bool inMemory) => _inMemory = inMemory; - - public SmidgeTestApp App { get; private set; } = null!; - - public async Task InitializeAsync() => App = await SmidgeTestApp.StartAsync(_inMemory); - - public async Task DisposeAsync() => await App.DisposeAsync(); - } - - public sealed class InMemoryCacheAppFixture : SmidgeAppFixture - { - public InMemoryCacheAppFixture() : base(inMemory: true) { } - } - - public sealed class PhysicalCacheAppFixture : SmidgeAppFixture - { - public PhysicalCacheAppFixture() : base(inMemory: false) { } - } - - public sealed class InMemoryCacheEndpointTests : SmidgeEndpointTestsBase, IClassFixture - { - public InMemoryCacheEndpointTests(InMemoryCacheAppFixture fixture) : base(fixture) { } - } - - public sealed class PhysicalCacheEndpointTests : SmidgeEndpointTestsBase, IClassFixture - { - public PhysicalCacheEndpointTests(PhysicalCacheAppFixture fixture) : base(fixture) { } - } -} diff --git a/test/Smidge.Tests/CacheFileSystemTests.cs b/test/Smidge.Tests/CacheFileSystemTests.cs index e9ec938..306400a 100644 --- a/test/Smidge.Tests/CacheFileSystemTests.cs +++ b/test/Smidge.Tests/CacheFileSystemTests.cs @@ -54,28 +54,5 @@ public void PhysicalCache_GetRequiredFileInfo_Missing_Throws() Assert.Throws(() => fs.GetRequiredFileInfo("does-not-exist.css")); } - - private sealed class TempFolder : IDisposable - { - public TempFolder() - { - Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "smidge-tests-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Path); - } - - public string Path { get; } - - public void Dispose() - { - try - { - Directory.Delete(Path, true); - } - catch - { - // best effort cleanup - } - } - } } } diff --git a/test/Smidge.Tests/PreProcessorPipelineTests.cs b/test/Smidge.Tests/PreProcessorPipelineTests.cs index 05dd50e..3d8f7ed 100644 --- a/test/Smidge.Tests/PreProcessorPipelineTests.cs +++ b/test/Smidge.Tests/PreProcessorPipelineTests.cs @@ -1,25 +1,21 @@ -using System; -using System.Diagnostics; using System.Threading.Tasks; using Moq; using Smidge.CompositeFiles; using Smidge.FileProcessors; using Smidge.Models; using Xunit; -using Xunit.Abstractions; namespace Smidge.Tests { public class PreProcessorPipelineTests { - [Fact] public async Task Can_Process_Pipeline() { var pipeline = new PreProcessPipeline(new IPreProcessor[] { - new ProcessorHeaderAndFooter(), - new ProcessorHeader(), + new ProcessorHeaderAndFooter(), + new ProcessorHeader(), new ProcessorFooter() }); using (var bc = BundleContext.CreateEmpty("1")) @@ -28,37 +24,6 @@ public async Task Can_Process_Pipeline() Assert.Equal("WrappedHeader\nHeader\nThis is some content\nFooter\nWrappedFooter", result); } - } - - private class ProcessorHeaderAndFooter : IPreProcessor - { - public async Task ProcessAsync(FileProcessContext fileProcessContext, PreProcessorDelegate next) - { - await next(fileProcessContext); - - fileProcessContext.Update("WrappedHeader\n" + fileProcessContext.FileContent + "\nWrappedFooter"); - } - } - - private class ProcessorHeader : IPreProcessor - { - public async Task ProcessAsync(FileProcessContext fileProcessContext, PreProcessorDelegate next) - { - await next(fileProcessContext); - fileProcessContext.Update("Header\n" + fileProcessContext.FileContent); - } - } - - private class ProcessorFooter : IPreProcessor - { - public async Task ProcessAsync(FileProcessContext fileProcessContext, PreProcessorDelegate next) - { - await next(fileProcessContext); - fileProcessContext.Update(fileProcessContext.FileContent + "\nFooter"); - } - } - - } -} \ No newline at end of file +} diff --git a/test/Smidge.Tests/ProcessorFooter.cs b/test/Smidge.Tests/ProcessorFooter.cs new file mode 100644 index 0000000..302702e --- /dev/null +++ b/test/Smidge.Tests/ProcessorFooter.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using Smidge.FileProcessors; + +namespace Smidge.Tests +{ + internal sealed class ProcessorFooter : IPreProcessor + { + public async Task ProcessAsync(FileProcessContext fileProcessContext, PreProcessorDelegate next) + { + await next(fileProcessContext); + fileProcessContext.Update(fileProcessContext.FileContent + "\nFooter"); + } + } +} diff --git a/test/Smidge.Tests/ProcessorHeader.cs b/test/Smidge.Tests/ProcessorHeader.cs new file mode 100644 index 0000000..3be9eab --- /dev/null +++ b/test/Smidge.Tests/ProcessorHeader.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using Smidge.FileProcessors; + +namespace Smidge.Tests +{ + internal sealed class ProcessorHeader : IPreProcessor + { + public async Task ProcessAsync(FileProcessContext fileProcessContext, PreProcessorDelegate next) + { + await next(fileProcessContext); + fileProcessContext.Update("Header\n" + fileProcessContext.FileContent); + } + } +} diff --git a/test/Smidge.Tests/ProcessorHeaderAndFooter.cs b/test/Smidge.Tests/ProcessorHeaderAndFooter.cs new file mode 100644 index 0000000..1440fca --- /dev/null +++ b/test/Smidge.Tests/ProcessorHeaderAndFooter.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using Smidge.FileProcessors; + +namespace Smidge.Tests +{ + internal sealed class ProcessorHeaderAndFooter : IPreProcessor + { + public async Task ProcessAsync(FileProcessContext fileProcessContext, PreProcessorDelegate next) + { + await next(fileProcessContext); + + fileProcessContext.Update("WrappedHeader\n" + fileProcessContext.FileContent + "\nWrappedFooter"); + } + } +} diff --git a/test/Smidge.Tests/TempFolder.cs b/test/Smidge.Tests/TempFolder.cs new file mode 100644 index 0000000..cf70f9e --- /dev/null +++ b/test/Smidge.Tests/TempFolder.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; + +namespace Smidge.Tests +{ + /// + /// A temporary directory that is deleted on dispose, used by file system tests. + /// + internal sealed class TempFolder : IDisposable + { + public TempFolder() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "smidge-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, true); + } + catch + { + // best effort cleanup + } + } + } +} From 83fb5ad4220a521e5aab11b0980697360dd79707 Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 12:35:37 -0600 Subject: [PATCH 08/12] Convert solution to slnx format Migrates Smidge.sln to the XML-based Smidge.slnx solution format and updates the CI build workflow to reference it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- Smidge.sln | 132 ------------------------------------ Smidge.slnx | 28 ++++++++ 3 files changed, 29 insertions(+), 133 deletions(-) delete mode 100644 Smidge.sln create mode 100644 Smidge.slnx diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6272293..69818f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest env: - Solution_File: Smidge.sln + Solution_File: Smidge.slnx Test_Proj: test/Smidge.Tests/Smidge.Tests.csproj Configuration: Release diff --git a/Smidge.sln b/Smidge.sln deleted file mode 100644 index 5446261..0000000 --- a/Smidge.sln +++ /dev/null @@ -1,132 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.12.35209.166 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A5154E3B-762A-4720-A947-C7A3EA25835A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{46C13455-622B-44B6-A129-F96EADAD24C8}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{92DBA67A-D9B9-4867-9519-DD846F47ED5C}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - .gitignore = .gitignore - .github\workflows\build.yml = .github\workflows\build.yml - src\Directory.Build.props = src\Directory.Build.props - LICENSE = LICENSE - GitVersion.yml.bak = GitVersion.yml.bak - Nuget.config = Nuget.config - README.md = README.md - .github\workflows\test-report.yml = .github\workflows\test-report.yml - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Smidge", "src\Smidge\Smidge.csproj", "{C304B5B8-0750-4B60-B6D0-06208639D560}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Smidge.Tests", "test\Smidge.Tests\Smidge.Tests.csproj", "{8C286364-9589-4B52-818E-657A9B12C172}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Smidge.Web", "src\Smidge.Web\Smidge.Web.csproj", "{89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Smidge.InMemory", "src\Smidge.InMemory\Smidge.InMemory.csproj", "{96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Smidge.Core", "src\Smidge.Core\Smidge.Core.csproj", "{B19C5049-69BA-4581-918F-6C2B4961E326}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Smidge.Integration.Tests", "test\Smidge.Integration.Tests\Smidge.Integration.Tests.csproj", "{AE340C50-9C14-4AF7-8EED-93C793C7BD82}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|x64.ActiveCfg = Debug|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|x64.Build.0 = Debug|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|x86.ActiveCfg = Debug|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Debug|x86.Build.0 = Debug|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|Any CPU.Build.0 = Release|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|x64.ActiveCfg = Release|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|x64.Build.0 = Release|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|x86.ActiveCfg = Release|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|x86.Build.0 = Release|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Debug|x64.ActiveCfg = Debug|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Debug|x64.Build.0 = Debug|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Debug|x86.ActiveCfg = Debug|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Debug|x86.Build.0 = Debug|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Release|Any CPU.Build.0 = Release|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Release|x64.ActiveCfg = Release|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Release|x64.Build.0 = Release|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Release|x86.ActiveCfg = Release|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Release|x86.Build.0 = Release|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|x64.ActiveCfg = Debug|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|x64.Build.0 = Debug|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|x86.ActiveCfg = Debug|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Debug|x86.Build.0 = Debug|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|Any CPU.Build.0 = Release|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|x64.ActiveCfg = Release|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|x64.Build.0 = Release|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|x86.ActiveCfg = Release|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|x86.Build.0 = Release|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|x64.ActiveCfg = Debug|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|x64.Build.0 = Debug|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|x86.ActiveCfg = Debug|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Debug|x86.Build.0 = Debug|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|Any CPU.Build.0 = Release|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|x64.ActiveCfg = Release|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|x64.Build.0 = Release|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|x86.ActiveCfg = Release|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|x86.Build.0 = Release|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|x64.ActiveCfg = Debug|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|x64.Build.0 = Debug|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|x86.ActiveCfg = Debug|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Debug|x86.Build.0 = Debug|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|Any CPU.Build.0 = Release|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|x64.ActiveCfg = Release|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|x64.Build.0 = Release|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|x86.ActiveCfg = Release|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|x86.Build.0 = Release|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|x64.ActiveCfg = Debug|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|x64.Build.0 = Debug|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|x86.ActiveCfg = Debug|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Debug|x86.Build.0 = Debug|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|Any CPU.Build.0 = Release|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|x64.ActiveCfg = Release|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|x64.Build.0 = Release|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|x86.ActiveCfg = Release|Any CPU - {AE340C50-9C14-4AF7-8EED-93C793C7BD82}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {C304B5B8-0750-4B60-B6D0-06208639D560} = {A5154E3B-762A-4720-A947-C7A3EA25835A} - {8C286364-9589-4B52-818E-657A9B12C172} = {46C13455-622B-44B6-A129-F96EADAD24C8} - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7} = {A5154E3B-762A-4720-A947-C7A3EA25835A} - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF} = {A5154E3B-762A-4720-A947-C7A3EA25835A} - {B19C5049-69BA-4581-918F-6C2B4961E326} = {A5154E3B-762A-4720-A947-C7A3EA25835A} - {AE340C50-9C14-4AF7-8EED-93C793C7BD82} = {46C13455-622B-44B6-A129-F96EADAD24C8} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {A0EB5714-BE56-4F17-B40A-8E8FADFDF503} - EndGlobalSection -EndGlobal diff --git a/Smidge.slnx b/Smidge.slnx new file mode 100644 index 0000000..6656e68 --- /dev/null +++ b/Smidge.slnx @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 492b09d8afde759d0008c124bac575bfcb6693b9 Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 12:52:10 -0600 Subject: [PATCH 09/12] Address PR review feedback - RequestModel: throw a clear InvalidOperationException when no active HttpContext is available instead of a NullReferenceException, and give the valueName ArgumentException a meaningful message. - CI: bump actions/setup-dotnet and actions/checkout to v4. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 4 ++-- src/Smidge/Models/RequestModel.cs | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69818f7..4cb5082 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -39,7 +39,7 @@ jobs: shell: pwsh - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: | 10.0.x diff --git a/src/Smidge/Models/RequestModel.cs b/src/Smidge/Models/RequestModel.cs index bdf698b..a9ff99f 100644 --- a/src/Smidge/Models/RequestModel.cs +++ b/src/Smidge/Models/RequestModel.cs @@ -11,12 +11,14 @@ public abstract class RequestModel : IRequestModel { protected RequestModel(string valueName, IUrlManager urlManager, IHttpContextAccessor httpContextAccessor, IRequestHelper requestHelper) { - if (string.IsNullOrWhiteSpace(valueName)) throw new ArgumentException("message", nameof(valueName)); + if (string.IsNullOrWhiteSpace(valueName)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(valueName)); if (urlManager is null) throw new ArgumentNullException(nameof(urlManager)); if (httpContextAccessor is null) throw new ArgumentNullException(nameof(httpContextAccessor)); if (requestHelper is null)throw new ArgumentNullException(nameof(requestHelper)); - var request = httpContextAccessor.HttpContext.Request; + var httpContext = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException($"{nameof(RequestModel)} can only be created during an active HTTP request but no {nameof(HttpContext)} is available."); + var request = httpContext.Request; //default LastFileWriteTime = DateTime.MinValue; From 16b9b5a88286697c2803b016cfcf53041def6dea Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 13:01:45 -0600 Subject: [PATCH 10/12] Move ISmidgeRequire implementations into Smidge.Core SmidgeRequire and NoopSmidgeRequire are framework-agnostic and only depend on types that already live in Smidge.Core (ISmidgeRequire, IBundleManager, IRequestHelper and the file models). Co-locating the implementations with their interface keeps the ASP.NET-free bundle configuration API entirely within the core layer. They remain internal; Core now grants InternalsVisibleTo to the Smidge project which consumes them from SmidgeHelper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/{Smidge => Smidge.Core}/NoopSmidgeRequire.cs | 0 src/{Smidge => Smidge.Core}/SmidgeRequire.cs | 3 +++ 2 files changed, 3 insertions(+) rename src/{Smidge => Smidge.Core}/NoopSmidgeRequire.cs (100%) rename src/{Smidge => Smidge.Core}/SmidgeRequire.cs (97%) diff --git a/src/Smidge/NoopSmidgeRequire.cs b/src/Smidge.Core/NoopSmidgeRequire.cs similarity index 100% rename from src/Smidge/NoopSmidgeRequire.cs rename to src/Smidge.Core/NoopSmidgeRequire.cs diff --git a/src/Smidge/SmidgeRequire.cs b/src/Smidge.Core/SmidgeRequire.cs similarity index 97% rename from src/Smidge/SmidgeRequire.cs rename to src/Smidge.Core/SmidgeRequire.cs index dc063ea..041a44e 100644 --- a/src/Smidge/SmidgeRequire.cs +++ b/src/Smidge.Core/SmidgeRequire.cs @@ -1,7 +1,10 @@ using System; using System.Linq; +using System.Runtime.CompilerServices; using Smidge.Models; +[assembly: InternalsVisibleTo("Smidge")] + namespace Smidge { internal class SmidgeRequire : ISmidgeRequire From 38f60d8463964e1b8d34a9744b9a6d3a9fe25412 Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 13:13:00 -0600 Subject: [PATCH 11/12] Honor If-None-Match precedence over If-Modified-Since for 304s Per RFC 7232 a request that contains an If-None-Match header must ignore If-Modified-Since. The previous OR-based check could return 304 when a non-matching ETag was combined with an If-Modified-Since indicating the content was unchanged. The filter now evaluates ETag precedence first and only falls back to the modified-since date when no If-None-Match header is present. Adds an integration regression test covering the mismatched-ETag plus unmodified-since case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CheckNotModifiedEndpointFilter.cs | 19 ++++++++++++--- .../SmidgeEndpointTests.cs | 23 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/Smidge/Controllers/CheckNotModifiedEndpointFilter.cs b/src/Smidge/Controllers/CheckNotModifiedEndpointFilter.cs index d9a6fae..cccf661 100644 --- a/src/Smidge/Controllers/CheckNotModifiedEndpointFilter.cs +++ b/src/Smidge/Controllers/CheckNotModifiedEndpointFilter.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; using Smidge.Hashing; using Smidge.Models; @@ -30,9 +31,21 @@ public async ValueTask InvokeAsync(EndpointFilterInvocationContext conte var etag = _hasher.Hash(file.FileKey + file.Compression + file.Mime); var request = context.HttpContext.Request; - var isDifferent = request.HasETagBeenModified(etag); - var hasChanged = request.HasRequestBeenModifiedSince(file.LastFileWriteTime.ToUniversalTime()); - if (!isDifferent || !hasChanged) + + // Per RFC 7232, If-None-Match takes precedence over If-Modified-Since: when an + // If-None-Match header is present the If-Modified-Since header must be ignored, + // otherwise a mismatched ETag combined with an unchanged date could wrongly 304. + bool notModified; + if (request.Headers.ContainsKey(HeaderNames.IfNoneMatch)) + { + notModified = !request.HasETagBeenModified(etag); + } + else + { + notModified = !request.HasRequestBeenModifiedSince(file.LastFileWriteTime.ToUniversalTime()); + } + + if (notModified) { return Results.StatusCode(StatusCodes.Status304NotModified); } diff --git a/test/Smidge.Integration.Tests/SmidgeEndpointTests.cs b/test/Smidge.Integration.Tests/SmidgeEndpointTests.cs index 6eea32f..ea1f03e 100644 --- a/test/Smidge.Integration.Tests/SmidgeEndpointTests.cs +++ b/test/Smidge.Integration.Tests/SmidgeEndpointTests.cs @@ -199,6 +199,29 @@ public async Task Conditional_Request_With_Matching_ETag_Returns_304() Assert.Equal(HttpStatusCode.NotModified, second.StatusCode); } + [Fact] + public async Task Conditional_Request_With_NonMatching_ETag_Is_Not_304_Even_When_Unmodified_Since() + { + // Per RFC 7232 If-None-Match takes precedence over If-Modified-Since. When a client sends a + // non-matching ETag it must receive the full response even if If-Modified-Since indicates the + // content is unchanged (the If-Modified-Since header must be ignored). + using var client = _app.CreateClient(); + + var urls = await GetUrlsAsync(client, "/urls/js/test-bundle-1"); + var bundleUrl = urls.Single(); + + using var first = await client.GetAsync(bundleUrl); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + using var conditional = new HttpRequestMessage(HttpMethod.Get, bundleUrl); + conditional.Headers.IfNoneMatch.Add(new EntityTagHeaderValue("\"this-does-not-match\"")); + // A far-future If-Modified-Since would, on its own, produce a 304. + conditional.Headers.IfModifiedSince = DateTimeOffset.UtcNow.AddYears(1); + + using var second = await client.SendAsync(conditional); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + } + [Fact] public async Task Compressed_Request_Returns_Gzip_Encoded_Body() { From 0a9ab2801c07d7330fb3f2ca2be616c3254abbc1 Mon Sep 17 00:00:00 2001 From: Shannon Deminick Date: Tue, 14 Jul 2026 14:01:48 -0600 Subject: [PATCH 12/12] Treat warnings as errors and clear existing warnings Enables TreatWarningsAsErrors solution-wide via a repo-root Directory.Build.props (chained from src/Directory.Build.props so the src projects pick it up too) and resolves the outstanding build warnings: - Smidge.Web: replace the obsolete WebHost/IWebHost startup (ASPDEPR008) with the generic Host.CreateDefaultBuilder().ConfigureWebHostDefaults() pattern returning IHost. - Smidge.Tests: drop the redundant System.Diagnostics.TraceSource package reference flagged by NU1510. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 8 ++++++++ src/Directory.Build.props | 3 +++ src/Smidge.Web/Startup.cs | 9 ++++----- test/Smidge.Tests/Smidge.Tests.csproj | 1 - 4 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..02c2493 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + true + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 688beba..5fb5ce4 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,5 +1,8 @@ + + + https://github.com/Shazwazza/Smidge diff --git a/src/Smidge.Web/Startup.cs b/src/Smidge.Web/Startup.cs index 10fa848..ab470ab 100644 --- a/src/Smidge.Web/Startup.cs +++ b/src/Smidge.Web/Startup.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Linq; -using Microsoft.AspNetCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -48,10 +47,10 @@ public static void Main(string[] args) BuildWebHost(args).Run(); } - public static IWebHost BuildWebHost(string[] args) => - WebHost.CreateDefaultBuilder(args) - .UseStartup() - .Build(); + public static IHost BuildWebHost(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()) + .Build(); public IConfigurationRoot Configuration { get; } public IWebHostEnvironment CurrentEnvironment { get; } diff --git a/test/Smidge.Tests/Smidge.Tests.csproj b/test/Smidge.Tests/Smidge.Tests.csproj index 91c6906..925aaea 100644 --- a/test/Smidge.Tests/Smidge.Tests.csproj +++ b/test/Smidge.Tests/Smidge.Tests.csproj @@ -22,7 +22,6 @@ -