From 514e4fa0d79486603382d8058c03a0a3cb9c12d3 Mon Sep 17 00:00:00 2001 From: Daniel Stolt Date: Tue, 7 Mar 2017 21:49:06 +0100 Subject: [PATCH] Implemented resource file hash-based cache busting. (#7564) --- .../Stubs/StubWorkContextAccessor.cs | 7 +- .../UI/Resources/ResourceManagerTests.cs | 5 +- .../Handlers/SiteSettingsPartHandler.cs | 1 + .../Core/Settings/Models/SiteSettingsPart.cs | 7 +- .../ViewModels/SiteSettingsPartViewModel.cs | 7 +- .../Parts.Settings.SiteSettingsPart.cshtml | 35 +++--- src/Orchard.Web/Core/Shapes/CoreShapes.cs | 14 ++- .../Modules/Orchard.Setup/SetupMode.cs | 5 + .../Tests/StubWorkContextAccessor.cs | 5 + .../TheThemeMachine/Styles/default-grid.css | 2 +- .../StylesheetBindingStrategy.cs | 19 ++-- src/Orchard/Environment/OrchardStarter.cs | 6 +- src/Orchard/Orchard.Framework.csproj | 3 + src/Orchard/Settings/ISite.cs | 1 + .../UI/Resources/IResourceFileHashProvider.cs | 5 + src/Orchard/UI/Resources/RequireSettings.cs | 18 +++- .../UI/Resources/ResourceDefinition.cs | 100 +++++++++++++++--- .../UI/Resources/ResourceFileHashProvider.cs | 39 +++++++ src/Orchard/UI/Resources/ResourceManager.cs | 46 ++++++-- .../UI/Resources/ResourceRequiredContext.cs | 8 +- 20 files changed, 263 insertions(+), 70 deletions(-) create mode 100644 src/Orchard/UI/Resources/IResourceFileHashProvider.cs create mode 100644 src/Orchard/UI/Resources/ResourceFileHashProvider.cs diff --git a/src/Orchard.Tests/Stubs/StubWorkContextAccessor.cs b/src/Orchard.Tests/Stubs/StubWorkContextAccessor.cs index b7ffb7fe6..551e1a199 100644 --- a/src/Orchard.Tests/Stubs/StubWorkContextAccessor.cs +++ b/src/Orchard.Tests/Stubs/StubWorkContextAccessor.cs @@ -66,7 +66,7 @@ namespace Orchard.Tests.Stubs { set { throw new NotImplementedException(); } } - public string SiteCalendar { + public string SiteCalendar { get { throw new NotImplementedException(); } set { throw new NotImplementedException(); } } @@ -81,6 +81,11 @@ namespace Orchard.Tests.Stubs { set { throw new NotImplementedException(); } } + public bool UseFileHash { + get { throw new NotImplementedException(); } + set { throw new NotImplementedException(); } + } + public int PageSize { get { throw new NotImplementedException(); } set { throw new NotImplementedException(); } diff --git a/src/Orchard.Tests/UI/Resources/ResourceManagerTests.cs b/src/Orchard.Tests/UI/Resources/ResourceManagerTests.cs index 5aba16765..7901f34d5 100644 --- a/src/Orchard.Tests/UI/Resources/ResourceManagerTests.cs +++ b/src/Orchard.Tests/UI/Resources/ResourceManagerTests.cs @@ -13,6 +13,7 @@ namespace Orchard.Tests.UI.Resources { public class ResourceManagerTests { private IContainer _container; private IResourceManager _resourceManager; + private IResourceFileHashProvider _resourceFileHashProvider; private TestManifestProvider _testManifest; private string _appPath = "/AppPath/"; @@ -37,7 +38,7 @@ namespace Orchard.Tests.UI.Resources { private void VerifyPaths(string resourceType, RequireSettings defaultSettings, string expectedPaths, bool ssl) { defaultSettings = defaultSettings ?? new RequireSettings(); var requiredResources = _resourceManager.BuildRequiredResources(resourceType); - var renderedResources = string.Join(",", requiredResources.Select(context => context.GetResourceUrl(defaultSettings, _appPath, ssl)).ToArray()); + var renderedResources = string.Join(",", requiredResources.Select(context => context.GetResourceUrl(defaultSettings, _appPath, ssl, _resourceFileHashProvider)).ToArray()); Assert.That(renderedResources, Is.EqualTo(expectedPaths)); } @@ -45,9 +46,11 @@ namespace Orchard.Tests.UI.Resources { public void Init() { var builder = new ContainerBuilder(); builder.RegisterType().As(); + builder.RegisterType().As(); builder.RegisterType().As().SingleInstance(); _container = builder.Build(); _resourceManager = _container.Resolve(); + _resourceFileHashProvider = _container.Resolve(); _testManifest = _container.Resolve() as TestManifestProvider; } diff --git a/src/Orchard.Web/Core/Settings/Handlers/SiteSettingsPartHandler.cs b/src/Orchard.Web/Core/Settings/Handlers/SiteSettingsPartHandler.cs index 97d987d16..bf08b0b6f 100644 --- a/src/Orchard.Web/Core/Settings/Handlers/SiteSettingsPartHandler.cs +++ b/src/Orchard.Web/Core/Settings/Handlers/SiteSettingsPartHandler.cs @@ -15,6 +15,7 @@ namespace Orchard.Core.Settings.Handlers { siteSettingsPart.SiteName = "My Orchard Project Application"; siteSettingsPart.PageTitleSeparator = " - "; siteSettingsPart.SiteTimeZone = TimeZoneInfo.Local.Id; + siteSettingsPart.UseFileHash = true; } } } \ No newline at end of file diff --git a/src/Orchard.Web/Core/Settings/Models/SiteSettingsPart.cs b/src/Orchard.Web/Core/Settings/Models/SiteSettingsPart.cs index 15b3686c1..53138085d 100644 --- a/src/Orchard.Web/Core/Settings/Models/SiteSettingsPart.cs +++ b/src/Orchard.Web/Core/Settings/Models/SiteSettingsPart.cs @@ -35,7 +35,7 @@ namespace Orchard.Core.Settings.Models { set { this.Store(x => x.SiteCulture, value); } } - public string SiteCalendar { + public string SiteCalendar { get { return this.Retrieve(x => x.SiteCalendar); } set { this.Store(x => x.SiteCalendar, value); } } @@ -50,6 +50,11 @@ namespace Orchard.Core.Settings.Models { set { this.Store(x=> x.UseCdn, value); } } + public bool UseFileHash { + get { return this.Retrieve(x => x.UseFileHash); } + set { this.Store(x => x.UseFileHash, value); } + } + public int PageSize { get { return this.Retrieve(x => x.PageSize, DefaultPageSize); } set { this.Store(x => x.PageSize, value); } diff --git a/src/Orchard.Web/Core/Settings/ViewModels/SiteSettingsPartViewModel.cs b/src/Orchard.Web/Core/Settings/ViewModels/SiteSettingsPartViewModel.cs index 5aa1caae7..af8617473 100644 --- a/src/Orchard.Web/Core/Settings/ViewModels/SiteSettingsPartViewModel.cs +++ b/src/Orchard.Web/Core/Settings/ViewModels/SiteSettingsPartViewModel.cs @@ -8,7 +8,7 @@ namespace Orchard.Core.Settings.ViewModels { public class SiteSettingsPartViewModel { public SiteSettingsPart Site { get; set; } public IEnumerable SiteCultures { get; set; } - public IEnumerable SiteCalendars { get; set; } + public IEnumerable SiteCalendars { get; set; } public IEnumerable TimeZones { get; set; } [HiddenInput(DisplayValue = false)] @@ -51,6 +51,11 @@ namespace Orchard.Core.Settings.ViewModels { set { Site.UseCdn = value; } } + public bool UseFileHash { + get { return Site.UseFileHash; } + set { Site.UseFileHash = value; } + } + public int PageSize { get { return Site.PageSize; } set { Site.PageSize = value; } diff --git a/src/Orchard.Web/Core/Settings/Views/EditorTemplates/Parts.Settings.SiteSettingsPart.cshtml b/src/Orchard.Web/Core/Settings/Views/EditorTemplates/Parts.Settings.SiteSettingsPart.cshtml index 746efc39b..bb04efbf9 100644 --- a/src/Orchard.Web/Core/Settings/Views/EditorTemplates/Parts.Settings.SiteSettingsPart.cshtml +++ b/src/Orchard.Web/Core/Settings/Views/EditorTemplates/Parts.Settings.SiteSettingsPart.cshtml @@ -23,20 +23,20 @@ @T("Enter the fully qualified base URL of the web site.") @T("e.g., http://localhost:30320/orchardlocal, http://www.yourdomain.com") -
- - @Html.DropDownList("SiteCulture", new SelectList(Model.SiteCultures, Model.SiteCulture)) - @Html.ValidationMessage("SiteCulture", "*") - @T("Determines the default culture used to localize strings and to format and parse numbers, date and times.") -

@Html.ActionLink(T("Add or remove supported cultures for the site").ToString(), "Culture")

-
-
- - @Html.DropDownList("SiteCalendar", new[] { new SelectListItem { Text = T("Culture calendar").Text, Value = "" } }.Union(new SelectList(Model.SiteCalendars, Model.SiteCalendar))) - @Html.ValidationMessage("SiteCalendar", "*") - @T("Determines the default calendar used when displaying and editing dates and times.") - @T("The 'Culture calendar' option means the default calendar for the culture of the current request will be used (not necessarily the configured default site culture).") -
+
+ + @Html.DropDownList("SiteCulture", new SelectList(Model.SiteCultures, Model.SiteCulture)) + @Html.ValidationMessage("SiteCulture", "*") + @T("Determines the default culture used to localize strings and to format and parse numbers, date and times.") +

@Html.ActionLink(T("Add or remove supported cultures for the site").ToString(), "Culture")

+
+
+ + @Html.DropDownList("SiteCalendar", new[] { new SelectListItem { Text = T("Culture calendar").Text, Value = "" } }.Union(new SelectList(Model.SiteCalendars, Model.SiteCalendar))) + @Html.ValidationMessage("SiteCalendar", "*") + @T("Determines the default calendar used when displaying and editing dates and times.") + @T("The 'Culture calendar' option means the default calendar for the culture of the current request will be used (not necessarily the configured default site culture).") +
@Html.DropDownList("TimeZone", new[] { new SelectListItem { Text = T("Local to server").Text, Value = "" } }.Union(new SelectList(Model.TimeZones, "Id", "", Model.TimeZone))) @@ -65,7 +65,12 @@
@Html.CheckBoxFor(m => m.UseCdn) @Html.LabelFor(m => m.UseCdn, T("Use CDN").Text, new { @class = "forcheckbox" }) - @T("Determines whether the defined CDN value is used for scripts and stylesheets, or their local version") + @T("Determines whether the defined CDN value is used for scripts and stylesheets, or their local version") +
+
+ @Html.CheckBoxFor(m => m.UseFileHash) + @Html.LabelFor(m => m.UseFileHash, T("Use Resource File Hashing").Text, new { @class = "forcheckbox" }) + @T("Determines whether MD5 file hashes are appended to the URLs for scripts and stylesheets (provides automatic cache busting).")
diff --git a/src/Orchard.Web/Core/Shapes/CoreShapes.cs b/src/Orchard.Web/Core/Shapes/CoreShapes.cs index 223cd8033..91b1c14b9 100644 --- a/src/Orchard.Web/Core/Shapes/CoreShapes.cs +++ b/src/Orchard.Web/Core/Shapes/CoreShapes.cs @@ -28,17 +28,20 @@ namespace Orchard.Core.Shapes { private readonly Work _resourceManager; private readonly Work _httpContextAccessor; private readonly Work _shapeFactory; + private readonly IResourceFileHashProvider _resourceFileHashProvider; public CoreShapes( Work workContext, Work resourceManager, Work httpContextAccessor, - Work shapeFactory + Work shapeFactory, + IResourceFileHashProvider resourceHashProvider ) { _workContext = workContext; _resourceManager = resourceManager; _httpContextAccessor = httpContextAccessor; _shapeFactory = shapeFactory; + _resourceFileHashProvider = resourceHashProvider; T = NullLocalizer.Instance; } @@ -448,6 +451,7 @@ namespace Orchard.Core.Shapes { var defaultSettings = new RequireSettings { DebugMode = debugMode, CdnMode = site.UseCdn, + FileHashMode = site.UseFileHash, Culture = _workContext.Value.CurrentCulture, }; var requiredResources = _resourceManager.Value.BuildRequiredResources(resourceType); @@ -460,18 +464,18 @@ namespace Orchard.Core.Shapes { (includeLocation.HasValue ? r.Settings.Location == includeLocation.Value : true) && (excludeLocation.HasValue ? r.Settings.Location != excludeLocation.Value : true))) { - var path = context.GetResourceUrl(defaultSettings, appPath, ssl); + var url = context.GetResourceUrl(defaultSettings, appPath, ssl, _resourceFileHashProvider); var condition = context.Settings.Condition; var attributes = context.Settings.HasAttributes ? context.Settings.Attributes : null; IHtmlString result; if (resourceType == "stylesheet") { - result = Display.Style(Url: path, Condition: condition, Resource: context.Resource, TagAttributes: attributes); + result = Display.Style(Url: url, Condition: condition, Resource: context.Resource, TagAttributes: attributes); } else if (resourceType == "script") { - result = Display.Script(Url: path, Condition: condition, Resource: context.Resource, TagAttributes: attributes); + result = Display.Script(Url: url, Condition: condition, Resource: context.Resource, TagAttributes: attributes); } else { - result = Display.Resource(Url: path, Condition: condition, Resource: context.Resource, TagAttributes: attributes); + result = Display.Resource(Url: url, Condition: condition, Resource: context.Resource, TagAttributes: attributes); } Output.Write(result); } diff --git a/src/Orchard.Web/Modules/Orchard.Setup/SetupMode.cs b/src/Orchard.Web/Modules/Orchard.Setup/SetupMode.cs index bea0c3f78..b81d43d3e 100644 --- a/src/Orchard.Web/Modules/Orchard.Setup/SetupMode.cs +++ b/src/Orchard.Web/Modules/Orchard.Setup/SetupMode.cs @@ -192,6 +192,11 @@ namespace Orchard.Setup { set { throw new NotImplementedException(); } } + public bool UseFileHash { + get { return false; } + set { throw new NotImplementedException(); } + } + public int PageSize { get { return SiteSettingsPart.DefaultPageSize; } set { throw new NotImplementedException(); } diff --git a/src/Orchard.Web/Modules/Orchard.Tokens/Tests/StubWorkContextAccessor.cs b/src/Orchard.Web/Modules/Orchard.Tokens/Tests/StubWorkContextAccessor.cs index a8f0668da..cab09485a 100644 --- a/src/Orchard.Web/Modules/Orchard.Tokens/Tests/StubWorkContextAccessor.cs +++ b/src/Orchard.Web/Modules/Orchard.Tokens/Tests/StubWorkContextAccessor.cs @@ -86,6 +86,11 @@ namespace Orchard.Tokens.Tests { set { throw new NotImplementedException(); } } + public bool UseFileHash { + get { throw new NotImplementedException(); } + set { throw new NotImplementedException(); } + } + public int PageSize { get { throw new NotImplementedException(); } set { throw new NotImplementedException(); } diff --git a/src/Orchard.Web/Themes/TheThemeMachine/Styles/default-grid.css b/src/Orchard.Web/Themes/TheThemeMachine/Styles/default-grid.css index debb9b310..d550197dc 100644 --- a/src/Orchard.Web/Themes/TheThemeMachine/Styles/default-grid.css +++ b/src/Orchard.Web/Themes/TheThemeMachine/Styles/default-grid.css @@ -12,7 +12,7 @@ margin-left: auto; margin-right: auto; } - + .row { display: block; margin: 0 0 20px 0; diff --git a/src/Orchard/DisplayManagement/Descriptors/ResourceBindingStrategy/StylesheetBindingStrategy.cs b/src/Orchard/DisplayManagement/Descriptors/ResourceBindingStrategy/StylesheetBindingStrategy.cs index ececdd071..bc0e93fd0 100644 --- a/src/Orchard/DisplayManagement/Descriptors/ResourceBindingStrategy/StylesheetBindingStrategy.cs +++ b/src/Orchard/DisplayManagement/Descriptors/ResourceBindingStrategy/StylesheetBindingStrategy.cs @@ -85,18 +85,19 @@ namespace Orchard.DisplayManagement.Descriptors.ResourceBindingStrategy { var featureDescriptors = hit.extensionDescriptor.Features.Where(fd => fd.Id == hit.extensionDescriptor.Id); foreach (var featureDescriptor in featureDescriptors) { builder.Describe(iter.shapeType) - .From(new Feature {Descriptor = featureDescriptor}) + .From(new Feature { Descriptor = featureDescriptor }) .BoundAs( hit.fileVirtualPath, shapeDescriptor => displayContext => { - var shape = ((dynamic) displayContext.Value); - var output = displayContext.ViewContext.Writer; - ResourceDefinition resource = shape.Resource; - string condition = shape.Condition; - Dictionary attributes = shape.TagAttributes; - ResourceManager.WriteResource(output, resource, hit.fileVirtualPath, condition, attributes); - return null; - }); + var shape = ((dynamic)displayContext.Value); + var output = displayContext.ViewContext.Writer; + ResourceDefinition resource = shape.Resource; + string url = shape.Url; + string condition = shape.Condition; + Dictionary attributes = shape.TagAttributes; + ResourceManager.WriteResource(output, resource, url ?? hit.fileVirtualPath, condition, attributes); + return null; + }); } } } diff --git a/src/Orchard/Environment/OrchardStarter.cs b/src/Orchard/Environment/OrchardStarter.cs index d3f52fa18..078a4b1f2 100644 --- a/src/Orchard/Environment/OrchardStarter.cs +++ b/src/Orchard/Environment/OrchardStarter.cs @@ -12,13 +12,13 @@ using Orchard.Caching; using Orchard.Data; using Orchard.Environment.AutofacUtil; using Orchard.Environment.Configuration; +using Orchard.Environment.Descriptor; using Orchard.Environment.Extensions; using Orchard.Environment.Extensions.Compilers; using Orchard.Environment.Extensions.Folders; using Orchard.Environment.Extensions.Loaders; using Orchard.Environment.ShellBuilders; using Orchard.Environment.State; -using Orchard.Environment.Descriptor; using Orchard.Events; using Orchard.Exceptions; using Orchard.FileSystems.AppData; @@ -33,10 +33,9 @@ using Orchard.Mvc.Filters; using Orchard.Mvc.ViewEngines.Razor; using Orchard.Mvc.ViewEngines.ThemeAwareness; using Orchard.Services; +using Orchard.UI.Resources; using Orchard.WebApi; using Orchard.WebApi.Filters; -using System.Linq; -using System.Web.Configuration; namespace Orchard.Environment { public static class OrchardStarter { @@ -74,6 +73,7 @@ namespace Orchard.Environment { builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); //builder.RegisterType().As().SingleInstance(); RegisterVolatileProvider(builder); diff --git a/src/Orchard/Orchard.Framework.csproj b/src/Orchard/Orchard.Framework.csproj index 498842d98..d5281b7d4 100644 --- a/src/Orchard/Orchard.Framework.csproj +++ b/src/Orchard/Orchard.Framework.csproj @@ -127,6 +127,7 @@ 3.0 + @@ -450,7 +451,9 @@ + + diff --git a/src/Orchard/Settings/ISite.cs b/src/Orchard/Settings/ISite.cs index 5539cb1ce..2936c5400 100644 --- a/src/Orchard/Settings/ISite.cs +++ b/src/Orchard/Settings/ISite.cs @@ -14,6 +14,7 @@ namespace Orchard.Settings { string SiteCalendar { get; set; } ResourceDebugMode ResourceDebugMode { get; set; } bool UseCdn { get; set; } + bool UseFileHash { get; set; } int PageSize { get; set; } int MaxPageSize { get; set; } int MaxPagedCount { get; set; } diff --git a/src/Orchard/UI/Resources/IResourceFileHashProvider.cs b/src/Orchard/UI/Resources/IResourceFileHashProvider.cs new file mode 100644 index 000000000..81e10108a --- /dev/null +++ b/src/Orchard/UI/Resources/IResourceFileHashProvider.cs @@ -0,0 +1,5 @@ +namespace Orchard.UI.Resources { + public interface IResourceFileHashProvider : ISingletonDependency { + string GetResourceFileHash(string physicalPath); + } +} diff --git a/src/Orchard/UI/Resources/RequireSettings.cs b/src/Orchard/UI/Resources/RequireSettings.cs index 161e12fb9..dd4cc340c 100644 --- a/src/Orchard/UI/Resources/RequireSettings.cs +++ b/src/Orchard/UI/Resources/RequireSettings.cs @@ -1,7 +1,6 @@ using System; -using System.Linq; using System.Collections.Generic; -using System.Web.Mvc; +using System.Linq; namespace Orchard.UI.Resources { public class RequireSettings { @@ -13,6 +12,7 @@ namespace Orchard.UI.Resources { public string Culture { get; set; } public bool DebugMode { get; set; } public bool CdnMode { get; set; } + public bool FileHashMode { get; set; } public ResourceLocation Location { get; set; } public string Condition { get; set; } public Action InlineDefinition { get; set; } @@ -69,8 +69,17 @@ namespace Orchard.UI.Resources { return UseCdn(true); } - public RequireSettings UseCdn(bool cdn) { - CdnMode |= cdn; + public RequireSettings UseCdn(bool cdnMode) { + CdnMode |= cdnMode; + return this; + } + + public RequireSettings UseFileHash() { + return UseFileHash(true); + } + + public RequireSettings UseFileHash(bool fileHashMode) { + FileHashMode |= fileHashMode; return this; } @@ -132,6 +141,7 @@ namespace Orchard.UI.Resources { .WithBasePath(BasePath).WithBasePath(other.BasePath) .UseCdn(CdnMode).UseCdn(other.CdnMode) .UseDebugMode(DebugMode).UseDebugMode(other.DebugMode) + .UseFileHash(FileHashMode).UseFileHash(other.FileHashMode) .UseCulture(Culture).UseCulture(other.Culture) .UseCondition(Condition).UseCondition(other.Condition) .Define(InlineDefinition).Define(other.InlineDefinition); diff --git a/src/Orchard/UI/Resources/ResourceDefinition.cs b/src/Orchard/UI/Resources/ResourceDefinition.cs index 37bd781fe..ff3532781 100644 --- a/src/Orchard/UI/Resources/ResourceDefinition.cs +++ b/src/Orchard/UI/Resources/ResourceDefinition.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Web; +using System.Web.Hosting; using System.Web.Mvc; namespace Orchard.UI.Resources { @@ -29,7 +30,8 @@ namespace Orchard.UI.Resources { }; private string _basePath; - private readonly Dictionary _urlResolveCache = new Dictionary(); + private string _physicalPath; + private string _physicalPathDebug; public ResourceDefinition(ResourceManifest manifest, string type, string name) { Manifest = manifest; @@ -63,7 +65,7 @@ namespace Orchard.UI.Resources { _resourceTypeDirectories.TryGetValue(resourceType, out path); return path ?? ""; } - + private static string Coalesce(params string[] strings) { foreach (var str in strings) { if (!String.IsNullOrEmpty(str)) { @@ -81,6 +83,11 @@ namespace Orchard.UI.Resources { public string Name { get; private set; } public string Type { get; private set; } public string Version { get; private set; } + public string Url { get; private set; } + public string UrlDebug { get; private set; } + public string UrlCdn { get; private set; } + public string UrlCdnDebug { get; private set; } + public string BasePath { get { if (!String.IsNullOrEmpty(_basePath)) { @@ -93,10 +100,25 @@ namespace Orchard.UI.Resources { return basePath ?? ""; } } - public string Url { get; private set; } - public string UrlDebug { get; private set; } - public string UrlCdn { get; private set; } - public string UrlCdnDebug { get; private set; } + + public string PhysicalPath { + get { + if (!String.IsNullOrEmpty(_physicalPath)) { + return _physicalPath; + } + return GetPhysicalPath(Url); + } + } + + public string PhysicalPathDebug { + get { + if (!String.IsNullOrEmpty(_physicalPathDebug)) { + return _physicalPathDebug; + } + return GetPhysicalPath(UrlDebug); + } + } + public string[] Cultures { get; private set; } public bool CdnSupportsSsl { get; private set; } public IEnumerable Dependencies { get; private set; } @@ -158,6 +180,21 @@ namespace Orchard.UI.Resources { return this; } + public ResourceDefinition SetPhysicalPath(string physicalPath) { + return SetPhysicalPath(physicalPath, null); + } + + public ResourceDefinition SetPhysicalPath(string physicalPath, string physicalPathDebug) { + if (String.IsNullOrEmpty(physicalPath)) { + throw new ArgumentNullException("physicalPath"); + } + _physicalPath = physicalPath; + if (physicalPathDebug != null) { + _physicalPathDebug = physicalPathDebug; + } + return this; + } + /// /// Sets the version of the resource. /// @@ -177,15 +214,13 @@ namespace Orchard.UI.Resources { return this; } - public string ResolveUrl(RequireSettings settings, string applicationPath) { - return ResolveUrl(settings, applicationPath, false); + public string ResolveUrl(RequireSettings settings, string applicationPath, IResourceFileHashProvider resourceFileHashProvider) { + return ResolveUrl(settings, applicationPath, false, resourceFileHashProvider); } - public string ResolveUrl(RequireSettings settings, string applicationPath, bool ssl) { + public string ResolveUrl(RequireSettings settings, string applicationPath, bool ssl, IResourceFileHashProvider resourceFileHashProvider) { string url; - if (_urlResolveCache.TryGetValue(settings, out url)) { - return url; - } + string physicalPath = null; // Url priority: if (!ssl || (ssl && CdnSupportsSsl)) { //Not ssl or ssl and cdn supports it if (settings.DebugMode) { @@ -204,6 +239,12 @@ namespace Orchard.UI.Resources { ? Coalesce(UrlDebug, Url) : Coalesce(Url, UrlDebug); } + if (url == UrlDebug) { + physicalPath = PhysicalPathDebug; + } + else if (url == Url) { + physicalPath = PhysicalPath; + } if (String.IsNullOrEmpty(url)) { return null; } @@ -222,11 +263,13 @@ namespace Orchard.UI.Resources { ? VirtualPathUtility.ToAbsolute(url, applicationPath) : VirtualPathUtility.ToAbsolute(url); } - _urlResolveCache[settings] = url; + if (settings.FileHashMode && !String.IsNullOrEmpty(physicalPath) && File.Exists(physicalPath)) { + url = AddQueryStringValue(url, "fileHash", resourceFileHashProvider.GetResourceFileHash(physicalPath)); + } return url; } - public string FindNearestCulture(string culture) { + private string FindNearestCulture(string culture) { // go for an exact match if (Cultures == null) { return null; @@ -261,5 +304,34 @@ namespace Orchard.UI.Resources { return (Name ?? "").GetHashCode() ^ (Type ?? "").GetHashCode(); } + private string GetPhysicalPath(string url) { + if (!String.IsNullOrEmpty(url) && !Uri.IsWellFormedUriString(url, UriKind.Absolute) && !url.StartsWith("//")) { + if (VirtualPathUtility.IsAbsolute(url) || VirtualPathUtility.IsAppRelative(url)) { + return HostingEnvironment.MapPath(url); + } + if (!String.IsNullOrEmpty(BasePath)) { + return HostingEnvironment.MapPath(VirtualPathUtility.Combine(BasePath, url)); + } + } + return null; + } + + private string AddQueryStringValue(string url, string name, string value) { + if (String.IsNullOrEmpty(url)) { + return null; + } + var encodedValue = HttpUtility.UrlEncode(value); + if (url.Contains("?")) { + if (url.EndsWith("&")) { + return String.Format("{0}{1}={2}", url, name, encodedValue); + } + else { + return String.Format("{0}&{1}={2}", url, name, encodedValue); + } + } + else { + return String.Format("{0}?{1}={2}", url, name, encodedValue); + } + } } } diff --git a/src/Orchard/UI/Resources/ResourceFileHashProvider.cs b/src/Orchard/UI/Resources/ResourceFileHashProvider.cs new file mode 100644 index 000000000..77a739391 --- /dev/null +++ b/src/Orchard/UI/Resources/ResourceFileHashProvider.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Security.Cryptography; + +namespace Orchard.UI.Resources { + public class ResourceFileHashProvider : IResourceFileHashProvider { + private ConcurrentDictionary _hashInfoCache = new ConcurrentDictionary(); + + public string GetResourceFileHash(string filePath) { + if (!File.Exists(filePath)) + throw new ArgumentException(String.Format("File with path '{0}' could not be found.", filePath), "physicalPath"); + var lastWriteTime = File.GetLastWriteTimeUtc(filePath); + var hashInfo = + _hashInfoCache.AddOrUpdate(filePath, + addValueFactory: (key) => new HashInfo(lastWriteTime, ComputeHash(filePath)), + updateValueFactory: (key, oldHashInfo) => oldHashInfo.LastWriteTime >= lastWriteTime ? oldHashInfo : new HashInfo(lastWriteTime, ComputeHash(filePath))); + return hashInfo.Hash; + } + + private string ComputeHash(string filePath) { + using (var md5 = MD5.Create()) { + using (var fileStream = File.OpenRead(filePath)) { + var hashBytes = md5.ComputeHash(fileStream); + return Convert.ToBase64String(hashBytes); + } + } + } + + private class HashInfo { + public HashInfo(DateTime lastWriteTime, string hash) { + LastWriteTime = lastWriteTime; + Hash = hash; + } + public readonly DateTime LastWriteTime; + public readonly string Hash; + } + } +} diff --git a/src/Orchard/UI/Resources/ResourceManager.cs b/src/Orchard/UI/Resources/ResourceManager.cs index 6fbd5b075..023a0ef59 100644 --- a/src/Orchard/UI/Resources/ResourceManager.cs +++ b/src/Orchard/UI/Resources/ResourceManager.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Web; +using System.Web.Hosting; using System.Web.Mvc; using Autofac.Features.Metadata; using Orchard.Environment.Extensions.Models; @@ -27,14 +28,14 @@ namespace Orchard.UI.Resources { private const string NotIE = "!IE"; private static string ToAppRelativePath(string resourcePath) { - if (!String.IsNullOrEmpty(resourcePath) && !Uri.IsWellFormedUriString(resourcePath, UriKind.Absolute)) { + if (!String.IsNullOrEmpty(resourcePath) && !Uri.IsWellFormedUriString(resourcePath, UriKind.Absolute) && !resourcePath.StartsWith("//")) { resourcePath = VirtualPathUtility.ToAppRelative(resourcePath); } return resourcePath; } - private static string FixPath(string resourcePath, string relativeFromPath) { - if (!String.IsNullOrEmpty(resourcePath) && !VirtualPathUtility.IsAbsolute(resourcePath) && !Uri.IsWellFormedUriString(resourcePath, UriKind.Absolute)) { + private static string ToAbsolutePath(string resourcePath, string relativeFromPath) { + if (!String.IsNullOrEmpty(resourcePath) && !VirtualPathUtility.IsAbsolute(resourcePath) && !Uri.IsWellFormedUriString(resourcePath, UriKind.Absolute) && !resourcePath.StartsWith("//")) { // appears to be a relative path (e.g. 'foo.js' or '../foo.js', not "/foo.js" or "http://..") if (String.IsNullOrEmpty(relativeFromPath)) { throw new InvalidOperationException("ResourcePath cannot be relative unless a base relative path is also provided."); @@ -44,6 +45,13 @@ namespace Orchard.UI.Resources { return resourcePath; } + private static string ToPhysicalPath(string resourcePath) { + if (!String.IsNullOrEmpty(resourcePath) && (VirtualPathUtility.IsAppRelative(resourcePath) || VirtualPathUtility.IsAbsolute(resourcePath)) && !Uri.IsWellFormedUriString(resourcePath, UriKind.Absolute) && !resourcePath.StartsWith("//")) { + return HostingEnvironment.MapPath(resourcePath); + } + return null; + } + private static TagBuilder GetTagBuilder(ResourceDefinition resource, string url) { var tagBuilder = new TagBuilder(resource.TagName); tagBuilder.MergeAttributes(resource.TagBuilder.Attributes); @@ -142,17 +150,27 @@ namespace Orchard.UI.Resources { throw new ArgumentNullException("resourcePath"); } - // ~/ ==> convert to absolute path (e.g. /orchard/..) + // Convert app-relative paths (~/) to absolute paths (e.g. /orchard/..) if (VirtualPathUtility.IsAppRelative(resourcePath)) { resourcePath = VirtualPathUtility.ToAbsolute(resourcePath); } if (resourceDebugPath != null && VirtualPathUtility.IsAppRelative(resourceDebugPath)) { resourceDebugPath = VirtualPathUtility.ToAbsolute(resourceDebugPath); } + + // Convert relative paths (e.g. dir/file.css) to absolute paths. + resourcePath = ToAbsolutePath(resourcePath, relativeFromPath); + resourceDebugPath = ToAbsolutePath(resourceDebugPath, relativeFromPath); - resourcePath = FixPath(resourcePath, relativeFromPath); - resourceDebugPath = FixPath(resourceDebugPath, relativeFromPath); - return Require(resourceType, ToAppRelativePath(resourcePath)).Define(d => d.SetUrl(resourcePath, resourceDebugPath)); + // Resolve absolute paths (not full URLs) to physical file paths. + var resourcePhysicalPath = ToPhysicalPath(resourcePath); + var resourceDebugPhysicalPath = ToPhysicalPath(resourceDebugPath); + + return Require(resourceType, ToAppRelativePath(resourcePath)).Define(d => { + d.SetUrl(resourcePath, resourceDebugPath); + if (resourcePhysicalPath != null) + d.SetPhysicalPath(resourcePhysicalPath, resourceDebugPhysicalPath); + }); } public virtual void RegisterHeadScript(string script) { @@ -266,8 +284,13 @@ namespace Orchard.UI.Resources { } ExpandDependencies(resource, settings, allResources); } - requiredResources = (from DictionaryEntry entry in allResources - select new ResourceRequiredContext { Resource = (ResourceDefinition)entry.Key, Settings = (RequireSettings)entry.Value }).ToList(); + requiredResources = ( + from DictionaryEntry entry in allResources + select new ResourceRequiredContext() { + Resource = (ResourceDefinition)entry.Key, + Settings = (RequireSettings)entry.Value + } + ).ToList(); _builtResources[resourceType] = requiredResources; return requiredResources; } @@ -285,8 +308,9 @@ namespace Orchard.UI.Resources { ? ((RequireSettings)allResources[resource]).Combine(settings) : new RequireSettings { Type = resource.Type, Name = resource.Name }.Combine(settings); if (resource.Dependencies != null) { - var dependencies = from d in resource.Dependencies - select FindResource(new RequireSettings { Type = resource.Type, Name = d }); + var dependencies = + from d in resource.Dependencies + select FindResource(new RequireSettings { Type = resource.Type, Name = d }); foreach (var dependency in dependencies) { if (dependency == null) { continue; diff --git a/src/Orchard/UI/Resources/ResourceRequiredContext.cs b/src/Orchard/UI/Resources/ResourceRequiredContext.cs index fb0656e82..87894ae22 100644 --- a/src/Orchard/UI/Resources/ResourceRequiredContext.cs +++ b/src/Orchard/UI/Resources/ResourceRequiredContext.cs @@ -6,15 +6,15 @@ namespace Orchard.UI.Resources { public ResourceDefinition Resource { get; set; } public RequireSettings Settings { get; set; } - public string GetResourceUrl(RequireSettings baseSettings, string appPath, bool ssl) { - return Resource.ResolveUrl(baseSettings == null ? Settings : baseSettings.Combine(Settings), appPath, ssl); + public string GetResourceUrl(RequireSettings baseSettings, string appPath, bool ssl, IResourceFileHashProvider resourceFileHashProvider) { + return Resource.ResolveUrl(baseSettings == null ? Settings : baseSettings.Combine(Settings), appPath, ssl, resourceFileHashProvider); } - public TagBuilder GetTagBuilder(RequireSettings baseSettings, string appPath) { + public TagBuilder GetTagBuilder(RequireSettings baseSettings, string appPath, IResourceFileHashProvider resourceFileHashProvider) { var tagBuilder = new TagBuilder(Resource.TagName); tagBuilder.MergeAttributes(Resource.TagBuilder.Attributes); if (!String.IsNullOrEmpty(Resource.FilePathAttributeName)) { - var resolvedUrl = GetResourceUrl(baseSettings, appPath, false); + var resolvedUrl = GetResourceUrl(baseSettings, appPath, false, resourceFileHashProvider); if (!String.IsNullOrEmpty(resolvedUrl)) { tagBuilder.MergeAttribute(Resource.FilePathAttributeName, resolvedUrl, true); }