From 7b15b71a4955f1fed7e564be9f4816d1f113eab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Kottal?= Date: Tue, 15 Nov 2022 22:59:57 +0100 Subject: [PATCH 1/3] Adds our-self-host --- .../Composing/SelfHostServiceComposer.cs | 15 ++ .../WebHostEnvironmentExtensions.cs | 48 +++++++ .../Models/SelfHostedFile.cs | 15 ++ Our.Umbraco.TagHelpers/SelfHostTagHelper.cs | 54 +++++++ .../Services/ISelfHostService.cs | 10 ++ .../Services/SelfHostService.cs | 132 ++++++++++++++++++ README.md | 29 ++++ 7 files changed, 303 insertions(+) create mode 100644 Our.Umbraco.TagHelpers/Composing/SelfHostServiceComposer.cs create mode 100644 Our.Umbraco.TagHelpers/Extensions/WebHostEnvironmentExtensions.cs create mode 100644 Our.Umbraco.TagHelpers/Models/SelfHostedFile.cs create mode 100644 Our.Umbraco.TagHelpers/SelfHostTagHelper.cs create mode 100644 Our.Umbraco.TagHelpers/Services/ISelfHostService.cs create mode 100644 Our.Umbraco.TagHelpers/Services/SelfHostService.cs diff --git a/Our.Umbraco.TagHelpers/Composing/SelfHostServiceComposer.cs b/Our.Umbraco.TagHelpers/Composing/SelfHostServiceComposer.cs new file mode 100644 index 0000000..f359e55 --- /dev/null +++ b/Our.Umbraco.TagHelpers/Composing/SelfHostServiceComposer.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using Our.Umbraco.TagHelpers.Services; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.DependencyInjection; + +namespace Our.Umbraco.TagHelpers.Composing +{ + public class SelfHostServiceComposer : IComposer + { + public void Compose(IUmbracoBuilder builder) + { + builder.Services.AddSingleton(); + } + } +} diff --git a/Our.Umbraco.TagHelpers/Extensions/WebHostEnvironmentExtensions.cs b/Our.Umbraco.TagHelpers/Extensions/WebHostEnvironmentExtensions.cs new file mode 100644 index 0000000..d4bbc31 --- /dev/null +++ b/Our.Umbraco.TagHelpers/Extensions/WebHostEnvironmentExtensions.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Hosting; +using System; +using System.IO; +using Umbraco.Cms.Core; + +namespace Our.Umbraco.TagHelpers.Extensions +{ + [Obsolete("This should be removed, when the package gets upgraded past Umbraco 10")] + internal static class WebHostEnvironmentExtensions + { + + /// + /// Maps a virtual path to a physical path to the application's web root + /// + /// + /// Depending on the runtime 'web root', this result can vary. For example in Net Framework the web root and the + /// content root are the same, however + /// in netcore the web root is /wwwroot therefore this will Map to a physical path within wwwroot. + /// + + [Obsolete("This should be removed, when the package gets upgraded past Umbraco 10")] + public static string MapPathWebRoot(this IWebHostEnvironment webHostEnvironment, string path) + { + var root = webHostEnvironment.WebRootPath; + + // Create if missing + if (string.IsNullOrWhiteSpace(root)) + { + root = webHostEnvironment.WebRootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"); + } + + var newPath = path.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + + // TODO: This is a temporary error because we switched from IOHelper.MapPath to HostingEnvironment.MapPathXXX + // IOHelper would check if the path passed in started with the root, and not prepend the root again if it did, + // however if you are requesting a path be mapped, it should always assume the path is relative to the root, not + // absolute in the file system. This error will help us find and fix improper uses, and should be removed once + // all those uses have been found and fixed + if (newPath.StartsWith(root)) + { + throw new ArgumentException( + "The path appears to already be fully qualified. Please remove the call to MapPathWebRoot"); + } + + return Path.Combine(root, newPath.TrimStart(Constants.CharArrays.TildeForwardSlashBackSlash)); + } + } +} diff --git a/Our.Umbraco.TagHelpers/Models/SelfHostedFile.cs b/Our.Umbraco.TagHelpers/Models/SelfHostedFile.cs new file mode 100644 index 0000000..1285a27 --- /dev/null +++ b/Our.Umbraco.TagHelpers/Models/SelfHostedFile.cs @@ -0,0 +1,15 @@ +namespace Our.Umbraco.TagHelpers.Models +{ + public class SelfHostedFile + { + public string? ExternalUrl { get; internal set; } + public string? FileName { get; internal set; } + public string? FolderPath { get; internal set; } + public string? Url { get; internal set; } + + public override string? ToString() + { + return Url; + } + } +} diff --git a/Our.Umbraco.TagHelpers/SelfHostTagHelper.cs b/Our.Umbraco.TagHelpers/SelfHostTagHelper.cs new file mode 100644 index 0000000..5528fc9 --- /dev/null +++ b/Our.Umbraco.TagHelpers/SelfHostTagHelper.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Razor.TagHelpers; +using Our.Umbraco.TagHelpers.Services; +using System.Threading.Tasks; +using Umbraco.Extensions; + +namespace Our.Umbraco.TagHelpers +{ + /// + /// Downloads the specified file (in href or src) to wwwroot and changes the link to local. + /// + [HtmlTargetElement("*", Attributes = "our-self-host")] + public class SelfHostTagHelper : TagHelper + { + private readonly ISelfHostService _selfHostService; + + public SelfHostTagHelper(ISelfHostService selfHostService) + { + _selfHostService = selfHostService; + } + + [HtmlAttributeName("folder")] + public string? FolderName { get; set; } + [HtmlAttributeName("src")] + public string? SrcAttribute { get; set; } + [HtmlAttributeName("href")] + public string? HrefAttribute { get; set; } + [HtmlAttributeName("ext")] + public string? Extension { get; set; } + public string Url => SrcAttribute.IfNullOrWhiteSpace(HrefAttribute); + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + var url = (Url.StartsWith("//") ? $"https:{Url}" : Url); + var selfHostedFile = await _selfHostService.SelfHostFile(url, FolderName, Extension); + + if (SrcAttribute.IsNullOrWhiteSpace() == false) + { + output.Attributes.SetAttribute("data-original-src", SrcAttribute); + output.Attributes.SetAttribute("src", selfHostedFile.Url); + } + else if (HrefAttribute.IsNullOrWhiteSpace() == false) + { + output.Attributes.SetAttribute("data-original-href", HrefAttribute); + output.Attributes.SetAttribute("href", selfHostedFile.Url); + } + + output.Attributes.Remove(new TagHelperAttribute("umb-self-host")); + output.Attributes.Remove(new TagHelperAttribute("folder")); + output.Attributes.Remove(new TagHelperAttribute("ext")); + } + + + } +} diff --git a/Our.Umbraco.TagHelpers/Services/ISelfHostService.cs b/Our.Umbraco.TagHelpers/Services/ISelfHostService.cs new file mode 100644 index 0000000..384b1b7 --- /dev/null +++ b/Our.Umbraco.TagHelpers/Services/ISelfHostService.cs @@ -0,0 +1,10 @@ +using Our.Umbraco.TagHelpers.Models; +using System.Threading.Tasks; + +namespace Our.Umbraco.TagHelpers.Services +{ + public interface ISelfHostService + { + Task SelfHostFile(string url, string? subfolder = null, string? fileExtension = null); + } +} \ No newline at end of file diff --git a/Our.Umbraco.TagHelpers/Services/SelfHostService.cs b/Our.Umbraco.TagHelpers/Services/SelfHostService.cs new file mode 100644 index 0000000..ae0ae4e --- /dev/null +++ b/Our.Umbraco.TagHelpers/Services/SelfHostService.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Our.Umbraco.TagHelpers.Extensions; +using Our.Umbraco.TagHelpers.Models; +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Logging; +using Umbraco.Extensions; + +namespace Our.Umbraco.TagHelpers.Services +{ + public class SelfHostService : ISelfHostService + { + private readonly IProfilingLogger _logger; + private readonly IAppPolicyCache _runtimeCache; + private readonly IWebHostEnvironment _hostingEnvironment; + private readonly IConfiguration _config; + + public SelfHostService( + IProfilingLogger logger, + IAppPolicyCache appPolicyCache, + IWebHostEnvironment hostingEnvironment, + IConfiguration config + ) + { + _logger = logger; + _runtimeCache = appPolicyCache; + _hostingEnvironment = hostingEnvironment; + _config = config; + } + + public async Task SelfHostFile(string url, string? subfolder = null, string? fileExtension = null) + { + return await _runtimeCache.GetCacheItem($"Our.Umbraco.TagHelpers.Services.SelfHostService.SelfHostedFile({url}, {subfolder}, {fileExtension})", async () => + { + using (_logger.TraceDuration($"Start generating SelfHostedFile: {url}", $"Finished generating SelfHostedFile: {url}")) + { + var uri = new Uri(url, UriKind.Absolute); + + var selfHostedFile = new SelfHostedFile() + { + ExternalUrl = url, + FileName = uri.Segments.Last() + fileExtension.IfNotNull(ext => ext.EnsureStartsWith(".")), + FolderPath = GetFolderPath(uri, subfolder) + }; + + selfHostedFile.Url = await GetSelfHostedUrl(selfHostedFile); + return selfHostedFile; + } + }); + } + + private string GetFolderPath(Uri uri, string? subfolder = null) + { + var folderPath = _config["Our.Umbraco.TagHelpers.SelfHost.RootFolder"].IfNullOrWhiteSpace("~/assets"); ; + + if (subfolder.IsNullOrWhiteSpace() == false) folderPath += subfolder.EnsureStartsWith("/"); + + folderPath += GetRemoteFolderPath(uri); + + return folderPath; + } + + private string GetRemoteFolderPath(Uri uri) + { + var segments = uri?.Segments; + + // if there is more than 2 segments (first segment is the root, last segment is the file) + // we can extract the folderpath + if (segments?.Length > 2) + { + segments = segments.Skip(1).SkipLast(1).ToArray(); + + // remove trailing slash from segments + segments = segments.Select(x => x.Replace("/", "")).ToArray(); + + // join segments with slash + return string.Join("/", segments).EnsureStartsWith("/"); + } + + return string.Empty; + } + private async Task GetSelfHostedUrl(SelfHostedFile file) + { + var filePath = $"{file.FolderPath}/{file.FileName}"; + var localPath = _hostingEnvironment.MapPathWebRoot(file.FolderPath); + var localFilePath = _hostingEnvironment.MapPathWebRoot(filePath); + + if (!File.Exists(localFilePath)) + { + using (_logger.TraceDuration($"Start downloading SelfHostedFile: {file.ExternalUrl} to {localFilePath}", $"Finished downloading SelfHostedFile: {file.ExternalUrl} to {localFilePath}")) + { + var content = await GetUrlContent(file.ExternalUrl); + if (content != null) + { + if (!Directory.Exists(localPath)) Directory.CreateDirectory(localPath); + await File.WriteAllBytesAsync(localFilePath, content); + return filePath; + } + else + { + return file.ExternalUrl; + } + } + } + + return filePath; + } + + private static async Task GetUrlContent(string url) + { + using (var client = new HttpClient()) + { + using (var result = await client.GetAsync(url)) + { + if (result is not null && result.IsSuccessStatusCode) + { + return await result.Content.ReadAsByteArrayAsync(); + } + else + { + return null; + } + } + } + } + } +} diff --git a/README.md b/README.md index 37848ef..e05a003 100644 --- a/README.md +++ b/README.md @@ -431,6 +431,35 @@ This example will turn off the automatic clearing of the tag helper cache if 'an ``` +## `our-self-host` +This is a tag helper attribute that can be applied to any element using a `src` or `href` attribute in the razor template or partial. It will automatically download and self hosting of third party assets, like javascript, css or images. + +### Simple Example +```cshtml +