From da1cf082e99155613be01af87cceac374aa9c8ad Mon Sep 17 00:00:00 2001 From: James Gunn Date: Fri, 10 May 2024 11:33:06 +0100 Subject: [PATCH] Add an appendVersion option to GenerateScriptImports and GenerateStyleImports --- CHANGELOG.md | 7 ++ .../HtmlHelperExtensions.cs | 61 +++++++++++++-- .../PageTemplateHelper.cs | 74 ++++++++++++++++--- .../Views/Shared/_GovUkPageTemplate.cshtml | 4 +- 4 files changed, 128 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0940ee3..6742e3a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +#### Page template + +New overloads of `GenerateScriptImports` and `GenerateStyleImports` have been added that accept an `appendVersion` parameter. +This appends a query string with a hash of the file's contents so that content changes following upgrades are seen by end users. + ## 2.0.0 Targets GOV.UK Frontend v5.1.0. diff --git a/src/GovUk.Frontend.AspNetCore/HtmlHelperExtensions.cs b/src/GovUk.Frontend.AspNetCore/HtmlHelperExtensions.cs index 877f0161..530f8385 100644 --- a/src/GovUk.Frontend.AspNetCore/HtmlHelperExtensions.cs +++ b/src/GovUk.Frontend.AspNetCore/HtmlHelperExtensions.cs @@ -10,7 +10,32 @@ namespace GovUk.Frontend.AspNetCore; /// public static class HtmlHelperExtensions { - /// + /// + /// Gets the CSP hash for the script that adds a js-enabled CSS class. + /// + /// The . + /// A hash to be included in your site's Content-Security-Policy header within the script-src directive. + public static string GetJsEnabledScriptCspHash(this IHtmlHelper htmlHelper) + { + ArgumentNullException.ThrowIfNull(htmlHelper); + var pageTemplateHelper = htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService(); + return pageTemplateHelper.GetJsEnabledScriptCspHash(); + } + + /// + /// Generates the script that adds a js-enabled CSS class. + /// + /// + /// + /// The contents of this property should be inserted at the beginning of the body tag. + /// + /// + /// Use the method to retrieve a CSP hash if you are not specifying . + /// + /// + /// The . + /// The CSP nonce attribute to be added to the generated script tag. + /// containing the script tag. public static IHtmlContent GovUkFrontendJsEnabledScript(this IHtmlHelper htmlHelper, string? cspNonce = null) { ArgumentNullException.ThrowIfNull(htmlHelper); @@ -18,19 +43,41 @@ public static IHtmlContent GovUkFrontendJsEnabledScript(this IHtmlHelper htmlHel return pageTemplateHelper.GenerateJsEnabledScript(cspNonce); } - /// - public static IHtmlContent GovUkFrontendScriptImports(this IHtmlHelper htmlHelper, string? cspNonce = null) + /// + /// Generates the script that adds a js-enabled CSS class. + /// + /// + /// + /// The contents of this property should be inserted at the beginning of the body tag. + /// + /// + /// Use the method to retrieve a CSP hash if you are not specifying . + /// + /// + /// The . + /// The CSP nonce attribute to be added to the generated script tag. + /// Whether the file version should be appended to the src attribute. + /// containing the script tag. + public static IHtmlContent GovUkFrontendScriptImports(this IHtmlHelper htmlHelper, string? cspNonce = null, bool appendVersion = true) { ArgumentNullException.ThrowIfNull(htmlHelper); var pageTemplateHelper = htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService(); - return pageTemplateHelper.GenerateScriptImports(cspNonce); + return pageTemplateHelper.GenerateScriptImports(cspNonce, appendVersion); } - /// - public static IHtmlContent GovUkFrontendStyleImports(this IHtmlHelper htmlHelper) + /// + /// Generates the HTML that imports the GOV.UK Frontend library styles. + /// + /// + /// The contents of this property should be inserted in the head tag. + /// + /// The . + /// Whether the file version should be appended to the href attribute. + /// containing the link tags. + public static IHtmlContent GovUkFrontendStyleImports(this IHtmlHelper htmlHelper, bool appendVersion = true) { ArgumentNullException.ThrowIfNull(htmlHelper); var pageTemplateHelper = htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService(); - return pageTemplateHelper.GenerateStyleImports(); + return pageTemplateHelper.GenerateStyleImports(appendVersion); } } diff --git a/src/GovUk.Frontend.AspNetCore/PageTemplateHelper.cs b/src/GovUk.Frontend.AspNetCore/PageTemplateHelper.cs index d29919f9..dab082d2 100644 --- a/src/GovUk.Frontend.AspNetCore/PageTemplateHelper.cs +++ b/src/GovUk.Frontend.AspNetCore/PageTemplateHelper.cs @@ -1,10 +1,13 @@ using System; +using System.Collections.Concurrent; +using System.IO; using System.Linq; using System.Reflection; using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Options; namespace GovUk.Frontend.AspNetCore; @@ -15,6 +18,9 @@ namespace GovUk.Frontend.AspNetCore; public class PageTemplateHelper { internal const string JsEnabledScript = "document.body.className += ' js-enabled' + ('noModule' in HTMLScriptElement.prototype ? ' govuk-frontend-supported' : '');"; + private const string VersionQueryParameterName = "v"; + + private static readonly ConcurrentDictionary _embeddedResourceFileVersionCache = new(); private readonly IOptions _optionsAccessor; @@ -63,19 +69,35 @@ public IHtmlContent GenerateJsEnabledScript(string? cspNonce = null) } /// - /// Generates the HTML that imports the GOV.UK Frontend library script and initializes it. + /// Generates the script that adds a js-enabled CSS class. /// /// /// - /// The contents of this property should be inserted at the end of the body tag. + /// The contents of this property should be inserted at the beginning of the body tag. /// /// - /// Use the method to retrieve a CSP hash if you are not specifying . + /// Use the method to retrieve a CSP hash if you are not specifying . /// /// - /// The CSP nonce attribute to be added to the generated initialization script tag. - /// containing the script tags. - public IHtmlContent GenerateScriptImports(string? cspNonce = null) + /// The CSP nonce attribute to be added to the generated script tag. + /// containing the script tag. + public IHtmlContent GenerateScriptImports(string? cspNonce = null) => GenerateScriptImports(cspNonce, appendVersion: false); + + /// + /// Generates the script that adds a js-enabled CSS class. + /// + /// + /// + /// The contents of this property should be inserted at the beginning of the body tag. + /// + /// + /// Use the method to retrieve a CSP hash if you are not specifying . + /// + /// + /// The CSP nonce attribute to be added to the generated script tag. + /// Whether the file version should be appended to the src attribute. + /// containing the script tag. + public IHtmlContent GenerateScriptImports(string? cspNonce = null, bool appendVersion = false) { var compiledContentPath = _optionsAccessor.Value.CompiledContentPath; if (compiledContentPath is null) @@ -92,9 +114,16 @@ public IHtmlContent GenerateScriptImports(string? cspNonce = null) TagBuilder GenerateImportScript() { + var src = $"{compiledContentPath}/all.min.js"; + if (appendVersion) + { + var version = _embeddedResourceFileVersionCache.GetOrAdd("Content/Compiled/all.min.js", path => GetEmbeddedResourceVersion(path)); + src = QueryHelpers.AddQueryString(src, VersionQueryParameterName, version); + } + var tagBuilder = new TagBuilder("script"); tagBuilder.MergeAttribute("type", "module"); - tagBuilder.MergeAttribute("src", $"{compiledContentPath}/all.min.js"); + tagBuilder.MergeAttribute("src", src); return tagBuilder; } @@ -121,7 +150,17 @@ TagBuilder GenerateInitScript() /// The contents of this property should be inserted in the head tag. /// /// containing the link tags. - public IHtmlContent GenerateStyleImports() + public IHtmlContent GenerateStyleImports() => GenerateStyleImports(appendVersion: false); + + /// + /// Generates the HTML that imports the GOV.UK Frontend library styles. + /// + /// + /// The contents of this property should be inserted in the head tag. + /// + /// Whether the file version should be appended to the src attribute. + /// containing the link tags. + public IHtmlContent GenerateStyleImports(bool appendVersion) { var compiledContentPath = _optionsAccessor.Value.CompiledContentPath; if (compiledContentPath is null) @@ -129,7 +168,14 @@ public IHtmlContent GenerateStyleImports() throw new InvalidOperationException($"Cannot generate style imports when {nameof(GovUkFrontendAspNetCoreOptions.CompiledContentPath)} is null."); } - return new HtmlString($""); + var href = $"{compiledContentPath}/all.min.css"; + if (appendVersion) + { + var version = _embeddedResourceFileVersionCache.GetOrAdd("Content/Compiled/all.min.css", path => GetEmbeddedResourceVersion(path)); + href = QueryHelpers.AddQueryString(href, VersionQueryParameterName, version); + } + + return new HtmlString($""); } /// @@ -174,4 +220,14 @@ private static string GenerateCspHash(string value) var hash = algo.ComputeHash(Encoding.UTF8.GetBytes(value)); return $"'sha256-{Convert.ToBase64String(hash)}'"; } + + private static string GetEmbeddedResourceVersion(string path) + { + using var resourceStream = typeof(PageTemplateHelper).Assembly.GetManifestResourceStream($"{path}") ?? + throw new ArgumentException($"Could not find resource: '{path}'.", nameof(path)); + using var ms = new MemoryStream(); + resourceStream.CopyTo(ms); + var hash = SHA256.HashData(ms.ToArray()); + return WebEncoders.Base64UrlEncode(hash); + } } diff --git a/src/GovUk.Frontend.AspNetCore/Views/Shared/_GovUkPageTemplate.cshtml b/src/GovUk.Frontend.AspNetCore/Views/Shared/_GovUkPageTemplate.cshtml index 940b6e70..0fdccb51 100644 --- a/src/GovUk.Frontend.AspNetCore/Views/Shared/_GovUkPageTemplate.cshtml +++ b/src/GovUk.Frontend.AspNetCore/Views/Shared/_GovUkPageTemplate.cshtml @@ -47,7 +47,7 @@ } else { - @Html.GovUkFrontendStyleImports() + @Html.GovUkFrontendStyleImports(appendVersion: true) } @@ -83,7 +83,7 @@ } else { - @Html.GovUkFrontendScriptImports(cspNonce) + @Html.GovUkFrontendScriptImports(cspNonce, appendVersion: true) }