diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be830c9..8c5a8d8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,13 +25,13 @@ jobs: runs-on: ubuntu-latest env: - Solution_File: Smidge.sln + Solution_File: Smidge.slnx Test_Proj: test/Smidge.Tests/Smidge.Tests.csproj Configuration: Release steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -42,11 +42,10 @@ jobs: shell: pwsh - name: Setup .NET Core SDK - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 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/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/Smidge.sln b/Smidge.sln deleted file mode 100644 index eb6c750..0000000 --- a/Smidge.sln +++ /dev/null @@ -1,73 +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 -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - 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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C304B5B8-0750-4B60-B6D0-06208639D560}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8C286364-9589-4B52-818E-657A9B12C172}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {89D8D7E5-4145-4A7E-A41A-EEDCB5FA7AE7}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {96EF3FF6-BD73-4495-B9C7-2A6C1C7B50CF}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B19C5049-69BA-4581-918F-6C2B4961E326}.Release|Any CPU.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} - 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index de80c4d..5fb5ce4 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,12 +1,15 @@ + + + https://github.com/Shazwazza/Smidge true true snupkg - 9.0 + latest @@ -23,6 +26,6 @@ 5.0.0 - net8.0;net6.0; + net10.0 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/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.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/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 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.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.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/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/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/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/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/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/CheckNotModifiedEndpointFilter.cs b/src/Smidge/Controllers/CheckNotModifiedEndpointFilter.cs new file mode 100644 index 0000000..cccf661 --- /dev/null +++ b/src/Smidge/Controllers/CheckNotModifiedEndpointFilter.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +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; + + // 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); + } + } + + 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/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/SmidgeRequestHandler.cs similarity index 78% rename from src/Smidge/Controllers/SmidgeController.cs rename to src/Smidge/Controllers/SmidgeRequestHandler.cs index c967114..9ac52dd 100644 --- a/src/Smidge/Controllers/SmidgeController.cs +++ b/src/Smidge/Controllers/SmidgeRequestHandler.cs @@ -6,8 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Smidge.Cache; @@ -19,14 +18,13 @@ namespace Smidge.Controllers { /// - /// Controller for handling minified/combined responses + /// Handles requests for minified/combined responses. /// - [AddCompressionHeader(Order = 0)] - [AddExpiryHeaders(Order = 1)] - [CheckNotModified(Order = 2)] - [CompositeFileCacheFilter(Order = 3)] - [AllowAnonymous] - public class SmidgeController : Controller + /// + /// 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. + /// + internal sealed class SmidgeRequestHandler { private static readonly ConcurrentDictionary s_locks = new ConcurrentDictionary(); @@ -38,22 +36,13 @@ public class SmidgeController : Controller private readonly ILogger _logger; private readonly CacheBusterResolver _cacheBusterResolver; - /// - /// Constructor - /// - /// - /// - /// - /// - /// - /// - public SmidgeController( + public SmidgeRequestHandler( ISmidgeFileSystem fileSystemHelper, IBundleManager bundleManager, IBundleFileSetGenerator fileSetGenerator, PreProcessPipelineFactory processorFactory, IPreProcessManager preProcessManager, - ILogger logger, + ILogger logger, CacheBusterResolver cacheBusterResolver) { _fileSystem = fileSystemHelper ?? throw new ArgumentNullException(nameof(fileSystemHelper)); @@ -68,19 +57,16 @@ public SmidgeController( /// /// Handles requests for named bundles /// - /// The bundle model - /// - public async Task Bundle( - [FromServices] BundleRequestModel bundleModel) + public async Task Bundle(BundleRequestModel bundleModel) { if (!bundleModel.IsBundleFound || !_bundleManager.TryGetValue(bundleModel.FileKey, out Bundle foundBundle)) { - return NotFound(); + return Results.NotFound(); } - if (TryGetBundle(bundleModel, out IActionResult actionResult, out string cacheFilePath)) + if (TryGetBundle(bundleModel, out IResult result, out string cacheFilePath)) { - return actionResult; + return result; } SemaphoreSlim bundleLock = s_locks.GetOrAdd(foundBundle.Name, s => new SemaphoreSlim(1, 1)); @@ -88,9 +74,9 @@ public async Task Bundle( try { // Double check, might be available now - if (TryGetBundle(bundleModel, out actionResult, out _)) + if (TryGetBundle(bundleModel, out result, out _)) { - return actionResult; + return result; } //the bundle doesn't exist so we'll go get the files, process them and create the bundle @@ -104,13 +90,13 @@ public async Task Bundle( if (files.Length == 0) { - return NotFound(); + 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 controller action. + // and the request is coming in directly to the handler. string cacheBusterValue = bundleModel.ParsedPath.CacheBusterValue; Type cacheBusterType = bundleOptions.GetCacheBusterType(); ICacheBuster cacheBuster = _cacheBusterResolver.GetCacheBuster(cacheBusterType); @@ -125,7 +111,7 @@ public async Task Bundle( cacheBusterValue, bundleModel.Bundle.Name, cacheBusterType); - return BadRequest(); + return Results.BadRequest(); } } @@ -159,14 +145,14 @@ public async Task Bundle( 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 + // 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 File(compressedStream, bundleModel.Mime); + return Results.Stream(compressedStream, bundleModel.Mime); } finally { @@ -181,14 +167,11 @@ public async Task Bundle( /// /// Handles requests for composite files (non-named bundles) /// - /// - /// - public async Task Composite( - [FromServices] CompositeFileModel file) + public async Task Composite(CompositeFileModel file) { if (!file.IsBundleFound || !file.ParsedPath.Names.Any()) { - return NotFound(); + return Results.NotFound(); } string cacheBusterValue = file.ParsedPath.CacheBusterValue; @@ -198,18 +181,18 @@ public async Task Composite( // 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); + // 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 File(cacheFile.CreateReadStream(), file.Mime); + 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 controller action. + // 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) @@ -223,24 +206,44 @@ public async Task Composite( cacheBusterValue, cacheFilePath, cacheBusterType); - return BadRequest(); + 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}")); + + // 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); await CacheCompositeFileAsync(_fileSystem.CacheFileSystem, cacheFilePath, compressedStream); - return File(compressedStream, file.Mime); + return Results.Stream(compressedStream, file.Mime); } - private bool TryGetBundle(BundleRequestModel bundleModel, out IActionResult actionResult, out string cacheFilePath) + 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; @@ -254,19 +257,19 @@ private bool TryGetBundle(BundleRequestModel bundleModel, out IActionResult acti 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); + //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 { - actionResult = File(cacheFile.CreateReadStream(), bundleModel.Mime); + result = Results.Stream(cacheFile.CreateReadStream(), bundleModel.Mime); return true; } } - actionResult = null; + result = null; return false; } @@ -282,9 +285,6 @@ private static async Task CacheCompositeFileAsync(ICacheFileSystem cacheProvider /// /// 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) 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/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..a9ff99f 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,23 @@ 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 (string.IsNullOrWhiteSpace(valueName)) throw new ArgumentException("Value cannot be null or whitespace.", 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 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; - 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/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/src/Smidge/Nuglify/NuglifySourceMapHandler.cs b/src/Smidge/Nuglify/NuglifySourceMapHandler.cs new file mode 100644 index 0000000..526f416 --- /dev/null +++ b/src/Smidge/Nuglify/NuglifySourceMapHandler.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +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. + /// + internal sealed class NuglifySourceMapHandler + { + private readonly ISmidgeFileSystem _fileSystem; + private readonly ILogger _logger; + + public NuglifySourceMapHandler(ISmidgeFileSystem fileSystem, ILogger logger) + { + _fileSystem = fileSystem; + _logger = logger; + } + + public IResult SourceMap(BundleRequestModel bundle) + { + if (!bundle.IsBundleFound) + { + return Results.NotFound(); + } + + // 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) + { + 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"); + } + } + + _logger.LogDebug("No source map exists for bundle {Bundle} at cache path {SourceMapPath}", bundle.FileKey, sourceMapFilePath); + 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) { 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/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/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/SmidgeEndpointTests.cs b/test/Smidge.Integration.Tests/SmidgeEndpointTests.cs new file mode 100644 index 0000000..ea1f03e --- /dev/null +++ b/test/Smidge.Integration.Tests/SmidgeEndpointTests.cs @@ -0,0 +1,301 @@ +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 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() + { + 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 ""; +}; diff --git a/test/Smidge.Tests/CacheFileSystemTests.cs b/test/Smidge.Tests/CacheFileSystemTests.cs new file mode 100644 index 0000000..306400a --- /dev/null +++ b/test/Smidge.Tests/CacheFileSystemTests.cs @@ -0,0 +1,58 @@ +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")); + } + } +} 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/Smidge.Tests.csproj b/test/Smidge.Tests/Smidge.Tests.csproj index fd91cfa..925aaea 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 @@ -10,6 +10,7 @@ + @@ -21,7 +22,6 @@ - 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 + } + } + } +}