Cache the SVG icons serverside to boost performance (#9200)

Co-authored-by: Nathan Woulfe <nathan@nathanw.com.au>
This commit is contained in:
Kenn Jacobsen
2021-03-09 12:58:10 +01:00
committed by GitHub
parent 4916c64848
commit 8e01ac30d6
12 changed files with 186 additions and 190 deletions

View File

@@ -25,6 +25,8 @@ namespace Umbraco.Core.IO
public static string AppPlugins => "~/App_Plugins";
public static string AppPluginIcons => "/Backoffice/Icons";
public static string MvcViews => "~/Views";
public static string PartialViews => MvcViews + "/Partials/";

View File

@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using Umbraco.Core.Models;
namespace Umbraco.Core.Services
@@ -6,7 +8,7 @@ namespace Umbraco.Core.Services
public interface IIconService
{
/// <summary>
/// Gets an IconModel containing the icon name and SvgString according to an icon name found at the global icons path
/// Gets the svg string for the icon name found at the global icons path
/// </summary>
/// <param name="iconName"></param>
/// <returns></returns>
@@ -15,7 +17,15 @@ namespace Umbraco.Core.Services
/// <summary>
/// Gets a list of all svg icons found at at the global icons path.
/// </summary>
/// <returns></returns>
/// <returns>A list of <see cref="IconModel"/></returns>
[Obsolete("This method should not be used - use GetIcons instead")]
[EditorBrowsable(EditorBrowsableState.Never)]
IList<IconModel> GetAllIcons();
/// <summary>
/// Gets a list of all svg icons found at at the global icons path.
/// </summary>
/// <returns></returns>
IReadOnlyDictionary<string, string> GetIcons();
}
}

View File

@@ -75,9 +75,8 @@ Icon with additional attribute. It can be treated like any other dom element
iconHelper.getIcon(icon)
.then(data => {
if (data !== null && data.svgString !== undefined) {
if (data && data.svgString) {
// Watch source SVG string
//icon.svgString.$$unwrapTrustedValue();
scope.svgString = data.svgString;
}
});

View File

@@ -3,9 +3,9 @@
* @name umbraco.services.iconHelper
* @description A helper service for dealing with icons, mostly dealing with legacy tree icons
**/
function iconHelper($http, $q, $sce, $timeout, umbRequestHelper) {
function iconHelper($http, $q, $sce, $timeout) {
var converter = [
const converter = [
{ oldIcon: ".sprNew", newIcon: "add" },
{ oldIcon: ".sprDelete", newIcon: "remove" },
{ oldIcon: ".sprMove", newIcon: "enter" },
@@ -85,15 +85,61 @@ function iconHelper($http, $q, $sce, $timeout, umbRequestHelper) {
{ oldIcon: ".sprTreeDeveloperPython", newIcon: "icon-linux" }
];
var collectedIcons;
let collectedIcons;
var imageConverter = [
{oldImage: "contour.png", newIcon: "icon-umb-contour"}
];
let imageConverter = [
{oldImage: "contour.png", newIcon: "icon-umb-contour"}
];
var iconCache = [];
var liveRequests = [];
var allIconsRequested = false;
const iconCache = [];
const promiseQueue = [];
let resourceLoadStatus = "none";
/**
* This is the same approach as use for loading the localized text json
* We don't want multiple requests for the icon collection, so need to track
* the current request state, and resolve the queued requests once the icons arrive
* Subsequent requests are returned immediately as the icons are cached into
*/
function init() {
const deferred = $q.defer();
if (resourceLoadStatus === "loaded") {
deferred.resolve(iconCache);
return deferred.promise;
}
if (resourceLoadStatus === "loading") {
promiseQueue.push(deferred);
return deferred.promise;
}
resourceLoadStatus = "loading";
$http({ method: "GET", url: Umbraco.Sys.ServerVariables.umbracoUrls.iconApiBaseUrl + 'GetIcons' })
.then(function (response) {
resourceLoadStatus = "loaded";
for (const [key, value] of Object.entries(response.data.Data)) {
iconCache.push({name: key, svgString: $sce.trustAsHtml(value)})
}
deferred.resolve(iconCache);
//ensure all other queued promises are resolved
for (let p in promiseQueue) {
promiseQueue[p].resolve(iconCache);
}
}, function (err) {
deferred.reject("Something broke");
//ensure all other queued promises are resolved
for (let p in promiseQueue) {
promiseQueue[p].reject("Something broke");
}
});
return deferred.promise;
}
return {
@@ -187,67 +233,12 @@ function iconHelper($http, $q, $sce, $timeout, umbRequestHelper) {
/** Gets a single IconModel */
getIcon: function(iconName) {
return $q((resolve, reject) => {
var icon = this._getIconFromCache(iconName);
if(icon !== undefined) {
resolve(icon);
} else {
var iconRequestPath = Umbraco.Sys.ServerVariables.umbracoUrls.iconApiBaseUrl + 'GetIcon?iconName=' + iconName;
// If the current icon is being requested, wait a bit so that we don't have to make another http request and can instead get the icon from the cache.
// This is a bit rough and ready and could probably be improved used an event based system
if(liveRequests.indexOf(iconRequestPath) >= 0) {
setTimeout(() => {
resolve(this.getIcon(iconName));
}, 10);
} else {
liveRequests.push(iconRequestPath);
// TODO - fix bug where Umbraco.Sys.ServerVariables.umbracoUrls.iconApiBaseUrl is undefinied when help icon
umbRequestHelper.resourcePromise(
$http.get(iconRequestPath)
,'Failed to retrieve icon: ' + iconName)
.then(icon => {
if(icon) {
var trustedIcon = this.defineIcon(icon.Name, icon.SvgString);
liveRequests = _.filter(liveRequests, iconRequestPath);
resolve(trustedIcon);
}
})
.catch(err => {
console.warn(err);
});
};
}
});
return init().then(icons => icons.find(i => i.name === iconName));
},
/** Gets all the available icons in the backoffice icon folder and returns them as an array of IconModels */
getAllIcons: function() {
return $q((resolve, reject) => {
if(allIconsRequested === false) {
allIconsRequested = true;
umbRequestHelper.resourcePromise(
$http.get(Umbraco.Sys.ServerVariables.umbracoUrls.iconApiBaseUrl + 'GetAllIcons')
,'Failed to retrieve icons')
.then(icons => {
icons.forEach(icon => {
this.defineIcon(icon.Name, icon.SvgString);
});
resolve(iconCache);
})
.catch(err => {
console.warn(err);
});;
} else {
resolve(iconCache);
}
});
return init().then(icons => icons);
},
/** LEGACY - Return a list of icons from icon fonts, optionally filter them */
@@ -312,9 +303,8 @@ function iconHelper($http, $q, $sce, $timeout, umbRequestHelper) {
},
/** Returns the cached icon or undefined */
_getIconFromCache: function(iconName) {
return _.find(iconCache, {name: iconName});
}
_getIconFromCache: iconName => iconCache.find(icon => icon.name === iconName)
};
}
angular.module('umbraco.services').factory('iconHelper', iconHelper);

View File

@@ -121,12 +121,6 @@
@Html.AngularValueResetPasswordCodeInfoScript(ViewData["PasswordResetCode"])
@Html.AngularValueTinyMceAssets()
app.run(["iconHelper", function (iconHelper) {
@* We inject icons to the icon helper(service), since icons can only be loaded if user is authorized. By injecting these to the service they will not be requested as they will become cached. *@
iconHelper.defineIcon("icon-check", '@Html.Raw(Model.IconCheckData)');
iconHelper.defineIcon("icon-delete", '@Html.Raw(Model.IconDeleteData)');
}]);
//required for the noscript trick
document.getElementById("mainwrapper").style.display = "inherit";

View File

@@ -1,4 +1,8 @@
using System;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin.Security;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -6,24 +10,19 @@ using System.Threading;
using System.Threading.Tasks;
using System.Web.Mvc;
using System.Web.UI;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin.Security;
using Newtonsoft.Json;
using Umbraco.Core;
using Umbraco.Core.Cache;
using Umbraco.Core.Configuration;
using Umbraco.Core.Logging;
using Umbraco.Core.Manifest;
using Umbraco.Core.Models.Identity;
using Umbraco.Web.Models;
using Umbraco.Web.Mvc;
using Umbraco.Core.Services;
using Umbraco.Web.Composing;
using Umbraco.Web.Features;
using Umbraco.Web.JavaScript;
using Umbraco.Web.Models;
using Umbraco.Web.Mvc;
using Umbraco.Web.Security;
using Umbraco.Web.Services;
using Constants = Umbraco.Core.Constants;
using JArray = Newtonsoft.Json.Linq.JArray;
@@ -40,11 +39,9 @@ namespace Umbraco.Web.Editors
private readonly ManifestParser _manifestParser;
private readonly UmbracoFeatures _features;
private readonly IRuntimeState _runtimeState;
private readonly IIconService _iconService;
private BackOfficeUserManager<BackOfficeIdentityUser> _userManager;
private BackOfficeSignInManager _signInManager;
[Obsolete("Use the constructor that injects IIconService.")]
public BackOfficeController(
ManifestParser manifestParser,
UmbracoFeatures features,
@@ -55,37 +52,11 @@ namespace Umbraco.Web.Editors
IProfilingLogger profilingLogger,
IRuntimeState runtimeState,
UmbracoHelper umbracoHelper)
: this(manifestParser,
features,
globalSettings,
umbracoContextAccessor,
services,
appCaches,
profilingLogger,
runtimeState,
umbracoHelper,
Current.IconService)
{
}
public BackOfficeController(
ManifestParser manifestParser,
UmbracoFeatures features,
IGlobalSettings globalSettings,
IUmbracoContextAccessor umbracoContextAccessor,
ServiceContext services,
AppCaches appCaches,
IProfilingLogger profilingLogger,
IRuntimeState runtimeState,
UmbracoHelper umbracoHelper,
IIconService iconService)
: base(globalSettings, umbracoContextAccessor, services, appCaches, profilingLogger, umbracoHelper)
{
_manifestParser = manifestParser;
_features = features;
_runtimeState = runtimeState;
_iconService = iconService;
}
protected BackOfficeSignInManager SignInManager => _signInManager ?? (_signInManager = OwinContext.GetBackOfficeSignInManager());
@@ -100,7 +71,7 @@ namespace Umbraco.Web.Editors
/// <returns></returns>
public async Task<ActionResult> Default()
{
var backofficeModel = new BackOfficeModel(_features, GlobalSettings, _iconService);
var backofficeModel = new BackOfficeModel(_features, GlobalSettings);
return await RenderDefaultOrProcessExternalLoginAsync(
() => View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/Default.cshtml", backofficeModel),
() => View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/Default.cshtml", backofficeModel));
@@ -186,7 +157,7 @@ namespace Umbraco.Web.Editors
{
return await RenderDefaultOrProcessExternalLoginAsync(
//The default view to render when there is no external login info or errors
() => View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/AuthorizeUpgrade.cshtml", new BackOfficeModel(_features, GlobalSettings, _iconService)),
() => View(GlobalSettings.Path.EnsureEndsWith('/') + "Views/AuthorizeUpgrade.cshtml", new BackOfficeModel(_features, GlobalSettings)),
//The ActionResult to perform if external login is successful
() => Redirect("/"));
}

View File

@@ -1,32 +1,19 @@
using System;
using Umbraco.Core.Configuration;
using Umbraco.Core.Services;
using Umbraco.Web.Composing;
using Umbraco.Web.Features;
namespace Umbraco.Web.Editors
{
public class BackOfficeModel
{
[Obsolete("Use the overload that injects IIconService.")]
public BackOfficeModel(UmbracoFeatures features, IGlobalSettings globalSettings) : this(features, globalSettings, Current.IconService)
{
}
public BackOfficeModel(UmbracoFeatures features, IGlobalSettings globalSettings, IIconService iconService)
public BackOfficeModel(UmbracoFeatures features, IGlobalSettings globalSettings)
{
Features = features;
GlobalSettings = globalSettings;
IconCheckData = iconService.GetIcon("icon-check")?.SvgString;
IconDeleteData = iconService.GetIcon("icon-delete")?.SvgString;
}
public UmbracoFeatures Features { get; }
public IGlobalSettings GlobalSettings { get; }
public string IconCheckData { get; }
public string IconDeleteData { get; }
}
}

View File

@@ -1,9 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using Umbraco.Core.Configuration;
using Umbraco.Core.Models;
using Umbraco.Core.Services;
using Umbraco.Web.Composing;
using Umbraco.Web.Features;
namespace Umbraco.Web.Editors
@@ -13,21 +10,11 @@ namespace Umbraco.Web.Editors
private readonly UmbracoFeatures _features;
public IEnumerable<ILanguage> Languages { get; }
[Obsolete("Use the overload that injects IIconService.")]
public BackOfficePreviewModel(
UmbracoFeatures features,
IGlobalSettings globalSettings,
IEnumerable<ILanguage> languages)
: this(features, globalSettings, languages, Current.IconService)
{
}
public BackOfficePreviewModel(
UmbracoFeatures features,
IGlobalSettings globalSettings,
IEnumerable<ILanguage> languages,
IIconService iconService)
: base(features, globalSettings, iconService)
: base(features, globalSettings)
{
_features = features;
Languages = languages;

View File

@@ -1,4 +1,6 @@
using System;
using ClientDependency.Core.Config;
using Microsoft.Owin;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Configuration;
@@ -7,15 +9,10 @@ using System.Runtime.Serialization;
using System.Web;
using System.Web.Configuration;
using System.Web.Mvc;
using ClientDependency.Core.Config;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using Umbraco.Core;
using Umbraco.Core.Composing;
using Umbraco.Core.Configuration;
using Umbraco.Core.Configuration.UmbracoSettings;
using Umbraco.Core.IO;
using Umbraco.Web.Controllers;
using Umbraco.Web.Features;
using Umbraco.Web.HealthCheck;
using Umbraco.Web.Models.ContentEditing;

View File

@@ -1,13 +1,20 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using Umbraco.Core.Models;
using Umbraco.Core.Services;
using Umbraco.Web.Mvc;
using Umbraco.Web.WebApi;
using Umbraco.Web.WebApi.Filters;
namespace Umbraco.Web.Editors
{
[PluginController("UmbracoApi")]
public class IconController : UmbracoAuthorizedApiController
[IsBackOffice]
[UmbracoWebApiRequireHttps]
[UnhandedExceptionLoggerConfiguration]
[EnableDetailedErrors]
public class IconController : UmbracoApiController
{
private readonly IIconService _iconService;
@@ -30,9 +37,22 @@ namespace Umbraco.Web.Editors
/// Gets a list of all svg icons found at at the global icons path.
/// </summary>
/// <returns></returns>
[Obsolete("This method should not be used - use GetIcons instead")]
public IList<IconModel> GetAllIcons()
{
return _iconService.GetAllIcons();
}
/// <summary>
/// Gets a list of all svg icons found at at the global icons path.
/// </summary>
/// <returns></returns>
public JsonNetResult GetIcons()
{
return new JsonNetResult(JsonNetResult.DefaultJsonSerializerSettings) {
Data = _iconService.GetIcons(),
Formatting = Formatting.None
};
}
}
}

View File

@@ -9,10 +9,8 @@ using Umbraco.Core.Services;
using Umbraco.Web.Composing;
using Umbraco.Web.Features;
using Umbraco.Web.JavaScript;
using Umbraco.Web.Models.ContentEditing;
using Umbraco.Web.Mvc;
using Umbraco.Web.PublishedCache;
using Umbraco.Web.Services;
using Constants = Umbraco.Core.Constants;
namespace Umbraco.Web.Editors
@@ -25,39 +23,19 @@ namespace Umbraco.Web.Editors
private readonly IPublishedSnapshotService _publishedSnapshotService;
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
private readonly ILocalizationService _localizationService;
private readonly IIconService _iconService;
[Obsolete("Use the constructor that injects IIconService.")]
public PreviewController(
UmbracoFeatures features,
IGlobalSettings globalSettings,
IPublishedSnapshotService publishedSnapshotService,
IUmbracoContextAccessor umbracoContextAccessor,
ILocalizationService localizationService)
:this(features,
globalSettings,
publishedSnapshotService,
umbracoContextAccessor,
localizationService,
Current.IconService)
{
}
public PreviewController(
UmbracoFeatures features,
IGlobalSettings globalSettings,
IPublishedSnapshotService publishedSnapshotService,
IUmbracoContextAccessor umbracoContextAccessor,
ILocalizationService localizationService,
IIconService iconService)
{
_features = features;
_globalSettings = globalSettings;
_publishedSnapshotService = publishedSnapshotService;
_umbracoContextAccessor = umbracoContextAccessor;
_localizationService = localizationService;
_iconService = iconService;
}
[UmbracoAuthorize(redirectToUmbracoLogin: true)]
@@ -74,7 +52,7 @@ namespace Umbraco.Web.Editors
availableLanguages = availableLanguages.Where(language => content.Cultures.ContainsKey(language.IsoCode));
}
var model = new BackOfficePreviewModel(_features, _globalSettings, availableLanguages, _iconService);
var model = new BackOfficePreviewModel(_features, _globalSettings, availableLanguages);
if (model.PreviewExtendedHeaderView.IsNullOrWhiteSpace() == false)
{

View File

@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Ganss.XSS;
using Umbraco.Core;
using Umbraco.Core.Cache;
using Umbraco.Core.Configuration;
using Umbraco.Core.IO;
using Umbraco.Core.Models;
@@ -14,31 +16,43 @@ namespace Umbraco.Web.Services
{
private readonly IGlobalSettings _globalSettings;
private readonly IHtmlSanitizer _htmlSanitizer;
private readonly IAppPolicyCache _cache;
public IconService(IGlobalSettings globalSettings, IHtmlSanitizer htmlSanitizer)
public IconService(IGlobalSettings globalSettings, IHtmlSanitizer htmlSanitizer, AppCaches appCaches)
{
_globalSettings = globalSettings;
_htmlSanitizer = htmlSanitizer;
_cache = appCaches.RuntimeCache;
}
/// <inheritdoc />
public IList<IconModel> GetAllIcons()
{
var directory = new DirectoryInfo(IOHelper.MapPath($"{_globalSettings.IconsPath}/"));
var iconNames = directory.GetFiles("*.svg");
public IReadOnlyDictionary<string, string> GetIcons() => GetIconDictionary();
return iconNames.OrderBy(f => f.Name)
.Select(iconInfo => GetIcon(iconInfo)).WhereNotNull().ToList();
}
/// <inheritdoc />
public IList<IconModel> GetAllIcons() =>
GetIconDictionary()
.Select(x => new IconModel { Name = x.Key, SvgString = x.Value })
.ToList();
/// <inheritdoc />
public IconModel GetIcon(string iconName)
{
return string.IsNullOrWhiteSpace(iconName)
? null
: CreateIconModel(iconName.StripFileExtension(), IOHelper.MapPath($"{_globalSettings.IconsPath}/{iconName}.svg"));
if (iconName.IsNullOrWhiteSpace())
{
return null;
}
var allIconModels = GetIconDictionary();
if (allIconModels.ContainsKey(iconName))
{
return new IconModel
{
Name = iconName,
SvgString = allIconModels[iconName]
};
}
return null;
}
/// <summary>
@@ -79,5 +93,52 @@ namespace Umbraco.Web.Services
return null;
}
}
private IEnumerable<FileInfo> GetAllIconsFiles()
{
var icons = new HashSet<FileInfo>(new CaseInsensitiveFileInfoComparer());
// add icons from plugins
var appPluginsDirectoryPath = IOHelper.MapPath(SystemDirectories.AppPlugins);
if (Directory.Exists(appPluginsDirectoryPath))
{
var appPlugins = new DirectoryInfo(appPluginsDirectoryPath);
// iterate sub directories of app plugins
foreach (var dir in appPlugins.EnumerateDirectories())
{
var iconPath = IOHelper.MapPath($"{SystemDirectories.AppPlugins}/{dir.Name}{SystemDirectories.AppPluginIcons}");
if (Directory.Exists(iconPath))
{
var dirIcons = new DirectoryInfo(iconPath).EnumerateFiles("*.svg", SearchOption.TopDirectoryOnly);
icons.UnionWith(dirIcons);
}
}
}
// add icons from IconsPath if not already added from plugins
var coreIconsDirectory = new DirectoryInfo(IOHelper.MapPath($"{_globalSettings.IconsPath}/"));
var coreIcons = coreIconsDirectory.GetFiles("*.svg");
icons.UnionWith(coreIcons);
return icons;
}
private class CaseInsensitiveFileInfoComparer : IEqualityComparer<FileInfo>
{
public bool Equals(FileInfo one, FileInfo two) => StringComparer.InvariantCultureIgnoreCase.Equals(one.Name, two.Name);
public int GetHashCode(FileInfo item) => StringComparer.InvariantCultureIgnoreCase.GetHashCode(item.Name);
}
private IReadOnlyDictionary<string, string> GetIconDictionary() => _cache.GetCacheItem(
$"{typeof(IconService).FullName}.{nameof(GetIconDictionary)}",
() => GetAllIconsFiles()
.Select(GetIcon)
.Where(i => i != null)
.GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().SvgString, StringComparer.OrdinalIgnoreCase)
);
}
}