Skip to content

Commit

Permalink
* Reworked Processors to not use physical file access, instead only u…
Browse files Browse the repository at this point in the history
…se IFileProvider (#316)

* * Extended tests of Processors
* Reworked Processors to not use physical file access, instead only use IFileProvider
* Added support for ASP.Net Core Applications in IIS subfolders to Processors

* Made UrlPathUtil public, so it can be used in other Processor Plugins
[release]
  • Loading branch information
alexreinert authored Jul 29, 2024
1 parent ad0636f commit 3952d89
Show file tree
Hide file tree
Showing 13 changed files with 679 additions and 192 deletions.
6 changes: 6 additions & 0 deletions WebOptimizer.Core.Mvc3/wwwroot/css/test/a.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ h1 {
padding-left: 64px;
}

h6 {
height: 64px;
background: url(../../img/a.png) no-repeat left center;
padding-left: 64px;
}

nav {
background: #e5e5e5;
}
Expand Down
89 changes: 50 additions & 39 deletions src/WebOptimizer.Core/Processors/CssFingerprinter.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
using WebOptimizer;
using WebOptimizer.Utils;

namespace WebOptimizer
{
Expand All @@ -19,54 +22,71 @@ public override Task ExecuteAsync(IAssetContext config)
{
var content = new Dictionary<string, byte[]>();
var env = (IWebHostEnvironment)config.HttpContext.RequestServices.GetService(typeof(IWebHostEnvironment));
var pipeline = (IAssetPipeline)config.HttpContext.RequestServices.GetService(typeof(IAssetPipeline));
IFileProvider fileProvider = config.Asset.GetFileProvider(env);

IFileProvider fileProvider = config.Asset.GetAssetFileProvider(env);

foreach (string key in config.Content.Keys)
{
IFileInfo input = fileProvider.GetFileInfo(key);
content[key] = Adjust(config.Content[key].AsString(), input, env);
content[key] = Adjust(config, key, fileProvider);
}

config.Content = content;

return Task.CompletedTask;
}

private static byte[] Adjust(string content, IFileInfo input, IWebHostEnvironment env)
private static byte[] Adjust(IAssetContext config, string key, IFileProvider fileProvider)
{
string inputDir = Path.GetDirectoryName(input.PhysicalPath);

Match match = _rxUrl.Match(content);

// Ignore references with protocols
if(match.Value.Contains("://") || match.Value.StartsWith("//") || match.Value.StartsWith("data:"))
content.AsByteArray();
string content = config.Content[key].AsString();

while (match.Success)
return _rxUrl.Replace(content, match =>
{
// no fingerprint on inline data
if (match.Value.StartsWith("data:"))
return match.Value;

string urlValue = match.Groups[3].Value;
string dir = inputDir;

//prevent query string from causing error
// no fingerprint on absolute urls
if (Uri.IsWellFormedUriString(urlValue, UriKind.Absolute))
return match.Value;

// no fingerprint if other host
if (urlValue.StartsWith("//"))
return match.Value;

// get absolute path of content file
string appPath = (config.HttpContext?.Request?.PathBase.HasValue ?? false)
? config.HttpContext.Request.PathBase.Value
: "/";

string routeRelativePath =
config.Asset.Route.StartsWith("~/")
? config.Asset.Route.Substring(2)
: config.Asset.Route;

string routePath = UrlPathUtils.MakeAbsolute(appPath, routeRelativePath);

string routeBasePath = UrlPathUtils.GetDirectory(routePath);

// prevent query string from causing error
string[] pathAndQuery = urlValue.Split(new[] { '?' }, 2, StringSplitOptions.RemoveEmptyEntries);
string pathOnly = pathAndQuery[0];
string queryOnly = pathAndQuery.Length == 2 ? pathAndQuery[1] : string.Empty;

if (pathOnly.StartsWith("/", StringComparison.Ordinal))
{
dir = env.WebRootPath;
}
// get filepath of included file
if (!UrlPathUtils.TryMakeAbsolutePathFromInclude(appPath, routeBasePath, pathOnly, out string filePath))
// path to included file is invalid
return match.Value;

var info = new FileInfo(Path.Combine(dir, pathOnly.TrimStart('/')));
// get FileInfo of included file
IFileInfo linkedFileInfo = fileProvider.GetFileInfo(filePath);

if (!info.Exists)
{
match = _rxUrl.Match(content, match.Index + match.Length);
continue;
}
// no fingerprint if file is not found
if (!linkedFileInfo.Exists)
return match.Value;

string hash = GenerateHash(info.LastWriteTime.Ticks.ToString());
string hash = GenerateHash(linkedFileInfo.LastModified.Ticks.ToString());
string withHash = pathOnly + $"?v={hash}";

if (!string.IsNullOrEmpty(queryOnly))
Expand All @@ -80,17 +100,8 @@ private static byte[] Adjust(string content, IFileInfo input, IWebHostEnvironmen
withHash +
match.Groups[4].Value;

string preMatchContent = content.Substring(0, match.Index);
string postMatchContent = content.Substring(match.Index + match.Length);

content = preMatchContent + replaced + postMatchContent;

//search next match from end of one just found (and replaced)
int startIndex = (preMatchContent + replaced).Length;
match = _rxUrl.Match(content, startIndex);
}

return content.AsByteArray();
return replaced;
}).AsByteArray();
}

private static string GenerateHash(string content)
Expand All @@ -115,7 +126,7 @@ public static partial class AssetPipelineExtensions

/// <summary>
/// Adds a fingerprint to local url() references.
/// NOTE: Make sure to call Concatinate() before this method
/// NOTE: Make sure to call this method before Concatinate()
/// </summary>
public static IAsset FingerprintUrls(this IAsset bundle)
{
Expand All @@ -127,7 +138,7 @@ public static IAsset FingerprintUrls(this IAsset bundle)

/// <summary>
/// Adds a fingerprint to local url() references.
/// NOTE: Make sure to call Concatinate() before this method
/// NOTE: Make sure to call this method before Concatinate()
/// </summary>
public static IEnumerable<IAsset> FingerprintUrls(this IEnumerable<IAsset> assets)
{
Expand Down
138 changes: 90 additions & 48 deletions src/WebOptimizer.Core/Processors/CssImageInliner.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using WebOptimizer;
using WebOptimizer.Utils;

namespace WebOptimizer
{
internal class CssImageInliner : Processor
{
private static readonly Regex _rxUrl = new Regex(@"url\s*\(\s*([""']?)([^:)]+)\1\s*\)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex _rxUrl = new Regex(@"(url\s*\(\s*)([""']?)([^:)]+)(\2\s*\))", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static int _maxFileSize;

public CssImageInliner(int maxFileSize)
Expand All @@ -23,70 +26,109 @@ public override async Task ExecuteAsync(IAssetContext config)
{
var content = new Dictionary<string, byte[]>();
var env = (IWebHostEnvironment)config.HttpContext.RequestServices.GetService(typeof(IWebHostEnvironment));
var pipeline = (IAssetPipeline)config.HttpContext.RequestServices.GetService(typeof(IAssetPipeline));
IFileProvider fileProvider = config.Asset.GetFileProvider(env);
IFileProvider fileProvider = config.Asset.GetAssetFileProvider(env);

foreach (string key in config.Content.Keys)
{
IFileInfo input = fileProvider.GetFileInfo(key);

content[key] = await InlineAsync(config.Content[key].AsString(), input, env);
content[key] = await InlineAsync(config, key, fileProvider);
}

config.Content = content;
}

private static async Task<byte[]> InlineAsync(string content, IFileInfo input, IWebHostEnvironment env)
private static async Task<byte[]> InlineAsync(IAssetContext config, string key, IFileProvider fileProvider)
{
MatchCollection matches = _rxUrl.Matches(content);
string inputDir = Path.GetDirectoryName(input.PhysicalPath);
string content = config.Content[key].AsString();

foreach (Match match in matches)
var sb = new StringBuilder();
int lastIndex = 0;

foreach (Match match in _rxUrl.Matches(content))
{
string urlValue = match.Groups[2].Value;
string dir = inputDir;

if (urlValue.Contains("://") || urlValue.StartsWith("//"))
{
continue;
}

string[] pathAndQuery = urlValue.Split(new[] { '?' }, 2, StringSplitOptions.RemoveEmptyEntries);
string pathOnly = pathAndQuery[0];
string queryOnly = pathAndQuery.Length == 2 ? pathAndQuery[1] : string.Empty;

if (pathOnly.StartsWith("/", StringComparison.Ordinal))
{
dir = env.WebRootPath;
}

var info = new FileInfo(Path.Combine(dir, pathOnly.TrimStart('/')));

if (!info.Exists)
{
continue;
}

if (info.Length > _maxFileSize && (!queryOnly.Contains("&inline") && !queryOnly.Contains("?inline")))
continue;

string mimeType = GetMimeTypeFromFileExtension(info.Name);

if (!string.IsNullOrEmpty(mimeType))
{
using (Stream fs = info.OpenRead())
{
string base64 = Convert.ToBase64String(await fs.AsBytesAsync());
string dataUri = $"url('data:{mimeType};base64,{base64}')";
content = content.Replace(match.Value, dataUri);
}
}
sb.Append(content, lastIndex, match.Index - lastIndex);
sb.Append(await ReplaceMatch(config, key, fileProvider, match));
lastIndex = match.Index + match.Length;
}

return content.AsByteArray();
sb.Append(content, lastIndex, content.Length - lastIndex);

return sb.ToString().AsByteArray();
}

private static async Task<string> ReplaceMatch(IAssetContext config, string key, IFileProvider fileProvider, Match match)
{
// no fingerprint on inline data
if (match.Value.StartsWith("data:"))
return match.Value;

string urlValue = match.Groups[3].Value;

// no fingerprint on absolute urls
if (Uri.IsWellFormedUriString(urlValue, UriKind.Absolute))
return match.Value;

// no fingerprint if other host
if (urlValue.StartsWith("//"))
return match.Value;

// get absolute path of content file
string appPath = (config.HttpContext?.Request?.PathBase.HasValue ?? false)
? config.HttpContext.Request.PathBase.Value
: "/";

string routeRelativePath =
config.Asset.Route.StartsWith("~/")
? config.Asset.Route.Substring(2)
: config.Asset.Route;

string routePath = UrlPathUtils.MakeAbsolute(appPath, routeRelativePath);

string routeBasePath = UrlPathUtils.GetDirectory(routePath);

// prevent query string from causing error
string[] pathAndQuery = urlValue.Split(new[] { '?' }, 2, StringSplitOptions.RemoveEmptyEntries);
string pathOnly = pathAndQuery[0];
string queryOnly = pathAndQuery.Length == 2 ? pathAndQuery[1] : string.Empty;

// get filepath of included file
if (!UrlPathUtils.TryMakeAbsolutePathFromInclude(appPath, routeBasePath, pathOnly, out string filePath))
// path to included file is invalid
return match.Value;

// get FileInfo of included file
IFileInfo linkedFileInfo = fileProvider.GetFileInfo(filePath);

// no fingerprint if file is not found
if (!linkedFileInfo.Exists)
return match.Value;

if (linkedFileInfo.Length > _maxFileSize &&
(!queryOnly.Contains("&inline") && !queryOnly.Contains("?inline")))
return match.Value;

string mimeType = GetMimeTypeFromFileExtension(linkedFileInfo.Name);

if (string.IsNullOrEmpty(mimeType))
return match.Value;

using (Stream fs = linkedFileInfo.CreateReadStream())
{
string base64 = Convert.ToBase64String(await fs.AsBytesAsync());
string dataUri = $"data:{mimeType};base64,{base64}";

string replaced =
match.Groups[1].Value +
match.Groups[2].Value +
dataUri +
match.Groups[4].Value;

return replaced;
}
}

static string GetMimeTypeFromFileExtension(string file)
private static string GetMimeTypeFromFileExtension(string file)
{
string ext = Path.GetExtension(file).TrimStart('.');

Expand Down
2 changes: 1 addition & 1 deletion src/WebOptimizer.Core/Processors/CssMinifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ public static IAsset AddCssBundle(this IAssetPipeline pipeline, string route, Cs
return pipeline.AddBundle(route, "text/css; charset=UTF-8", sourceFiles)
.EnforceFileExtensions(".css")
.AdjustRelativePaths()
.Concatenate()
.FingerprintUrls()
.Concatenate()
.AddResponseHeader("X-Content-Type-Options", "nosniff")
.MinifyCss(settings);
}
Expand Down
Loading

0 comments on commit 3952d89

Please sign in to comment.