461 lines
19 KiB
C#
Executable File
461 lines
19 KiB
C#
Executable File
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Web.Caching;
|
|
using Umbraco.Core;
|
|
using Umbraco.Core.Cache;
|
|
using Umbraco.Core.Configuration.UmbracoSettings;
|
|
using Umbraco.Core.Events;
|
|
using Umbraco.Core.IO;
|
|
using Umbraco.Core.Logging;
|
|
using Umbraco.Core.Macros;
|
|
using Umbraco.Core.Models;
|
|
using Umbraco.Core.Models.PublishedContent;
|
|
using Umbraco.Core.Services;
|
|
|
|
namespace Umbraco.Web.Macros
|
|
{
|
|
internal class MacroRenderer : IMacroRenderer
|
|
{
|
|
private readonly IProfilingLogger _plogger;
|
|
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
|
|
private readonly IContentSection _contentSection;
|
|
private readonly ILocalizedTextService _textService;
|
|
private readonly AppCaches _appCaches;
|
|
private readonly IMacroService _macroService;
|
|
|
|
public MacroRenderer(IProfilingLogger plogger, IUmbracoContextAccessor umbracoContextAccessor, IContentSection contentSection, ILocalizedTextService textService, AppCaches appCaches, IMacroService macroService)
|
|
{
|
|
_plogger = plogger ?? throw new ArgumentNullException(nameof(plogger));
|
|
_umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor));
|
|
_contentSection = contentSection ?? throw new ArgumentNullException(nameof(contentSection));
|
|
_textService = textService;
|
|
_appCaches = appCaches ?? throw new ArgumentNullException(nameof(appCaches));
|
|
_macroService = macroService ?? throw new ArgumentNullException(nameof(macroService));
|
|
}
|
|
|
|
#region MacroContent cache
|
|
|
|
// gets this macro content cache identifier
|
|
private string GetContentCacheIdentifier(MacroModel model, int pageId)
|
|
{
|
|
var id = new StringBuilder();
|
|
|
|
var alias = model.Alias;
|
|
id.AppendFormat("{0}-", alias);
|
|
|
|
if (model.CacheByPage)
|
|
id.AppendFormat("{0}-", pageId);
|
|
|
|
if (model.CacheByMember)
|
|
{
|
|
object key = 0;
|
|
|
|
if (_umbracoContextAccessor.UmbracoContext.HttpContext?.User?.Identity?.IsAuthenticated ?? false)
|
|
{
|
|
//ugh, membershipproviders :(
|
|
var provider = Core.Security.MembershipProviderExtensions.GetMembersMembershipProvider();
|
|
var member = Core.Security.MembershipProviderExtensions.GetCurrentUser(provider);
|
|
key = member?.ProviderUserKey ?? 0;
|
|
}
|
|
|
|
id.AppendFormat("m{0}-", key);
|
|
}
|
|
|
|
foreach (var value in model.Properties.Select(x => x.Value))
|
|
id.AppendFormat("{0}-", value.Length <= 255 ? value : value.Substring(0, 255));
|
|
|
|
return id.ToString();
|
|
}
|
|
|
|
// gets this macro content from the cache
|
|
// ensuring that it is appropriate to use the cache
|
|
private MacroContent GetMacroContentFromCache(MacroModel model)
|
|
{
|
|
// only if cache is enabled
|
|
if (_umbracoContextAccessor.UmbracoContext.InPreviewMode || model.CacheDuration <= 0) return null;
|
|
|
|
var cache = _appCaches.RuntimeCache;
|
|
var macroContent = cache.GetCacheItem<MacroContent>(CacheKeys.MacroContentCacheKey + model.CacheIdentifier);
|
|
|
|
if (macroContent == null) return null;
|
|
|
|
_plogger.Debug<MacroRenderer>("Macro content loaded from cache '{MacroCacheId}'", model.CacheIdentifier);
|
|
|
|
// ensure that the source has not changed
|
|
// note: does not handle dependencies, and never has
|
|
var macroSource = GetMacroFile(model); // null if macro is not file-based
|
|
if (macroSource != null)
|
|
{
|
|
if (macroSource.Exists == false)
|
|
{
|
|
_plogger.Debug<MacroRenderer>("Macro source does not exist anymore, ignore cache.");
|
|
return null;
|
|
}
|
|
|
|
if (macroContent.Date < macroSource.LastWriteTime)
|
|
{
|
|
_plogger.Debug<MacroRenderer>("Macro source has changed, ignore cache.");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// this is legacy and I'm not sure what exactly it is supposed to do
|
|
if (macroContent.Control != null)
|
|
macroContent.Control.ID = macroContent.ControlId;
|
|
|
|
return macroContent;
|
|
}
|
|
|
|
// stores macro content into the cache
|
|
private void AddMacroContentToCache(MacroModel model, MacroContent macroContent)
|
|
{
|
|
// only if cache is enabled
|
|
if (_umbracoContextAccessor.UmbracoContext.InPreviewMode || model.CacheDuration <= 0) return;
|
|
|
|
// just make sure...
|
|
if (macroContent == null) return;
|
|
|
|
// do not cache if it should cache by member and there's not member
|
|
if (model.CacheByMember)
|
|
{
|
|
var provider = Core.Security.MembershipProviderExtensions.GetMembersMembershipProvider();
|
|
var member = Core.Security.MembershipProviderExtensions.GetCurrentUser(provider);
|
|
var key = member?.ProviderUserKey;
|
|
if (key == null) return;
|
|
}
|
|
|
|
// this is legacy and I'm not sure what exactly it is supposed to do
|
|
if (macroContent.Control != null)
|
|
macroContent.ControlId = macroContent.Control.ID;
|
|
|
|
// remember when we cache the content
|
|
macroContent.Date = DateTime.Now;
|
|
|
|
var cache = _appCaches.RuntimeCache;
|
|
cache.Insert(
|
|
CacheKeys.MacroContentCacheKey + model.CacheIdentifier,
|
|
() => macroContent,
|
|
new TimeSpan(0, 0, model.CacheDuration),
|
|
priority: CacheItemPriority.NotRemovable
|
|
);
|
|
|
|
_plogger.Debug<MacroRenderer>("Macro content saved to cache '{MacroCacheId}'", model.CacheIdentifier);
|
|
}
|
|
|
|
// gets the macro source file name
|
|
// null if the macro is not file-based
|
|
internal static string GetMacroFileName(MacroModel model)
|
|
{
|
|
string filename;
|
|
|
|
switch (model.MacroType)
|
|
{
|
|
case MacroTypes.PartialView:
|
|
filename = model.MacroSource; // partial views are saved with their full virtual path
|
|
break;
|
|
default:
|
|
// not file-based, or not supported
|
|
filename = null;
|
|
break;
|
|
}
|
|
|
|
return filename;
|
|
}
|
|
|
|
// gets the macro source file
|
|
// null if macro is not file-based
|
|
private static FileInfo GetMacroFile(MacroModel model)
|
|
{
|
|
var filename = GetMacroFileName(model);
|
|
if (filename == null) return null;
|
|
|
|
var mapped = IOHelper.MapPath(filename);
|
|
if (mapped == null) return null;
|
|
|
|
var file = new FileInfo(mapped);
|
|
return file.Exists ? file : null;
|
|
}
|
|
|
|
// updates the model properties values according to the attributes
|
|
private static void UpdateMacroModelProperties(MacroModel model, IDictionary<string, object> macroParams)
|
|
{
|
|
foreach (var prop in model.Properties)
|
|
{
|
|
var key = prop.Key.ToLowerInvariant();
|
|
prop.Value = macroParams.ContainsKey(key)
|
|
? macroParams[key]?.ToString() ?? string.Empty
|
|
: string.Empty;
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Render/Execute
|
|
|
|
public MacroContent Render(string macroAlias, IPublishedContent content, IDictionary<string, object> macroParams)
|
|
{
|
|
var m = _macroService.GetByAlias(macroAlias);
|
|
if (m == null)
|
|
throw new InvalidOperationException("No macro found by alias " + macroAlias);
|
|
|
|
var page = new PublishedContentHashtableConverter(content);
|
|
|
|
var macro = new MacroModel(m);
|
|
|
|
UpdateMacroModelProperties(macro, macroParams);
|
|
return Render(macro, content, page.Elements);
|
|
}
|
|
|
|
private MacroContent Render(MacroModel macro, IPublishedContent content, IDictionary pageElements)
|
|
{
|
|
if (content == null) throw new ArgumentNullException(nameof(content));
|
|
|
|
var macroInfo = $"Render Macro: {macro.Name}, type: {macro.MacroType}, cache: {macro.CacheDuration}";
|
|
using (_plogger.DebugDuration<MacroRenderer>(macroInfo, "Rendered Macro."))
|
|
{
|
|
// parse macro parameters ie replace the special [#key], [$key], etc. syntaxes
|
|
foreach (var prop in macro.Properties)
|
|
prop.Value = ParseAttribute(pageElements, prop.Value);
|
|
|
|
macro.CacheIdentifier = GetContentCacheIdentifier(macro, content.Id);
|
|
|
|
// get the macro from cache if it is there
|
|
var macroContent = GetMacroContentFromCache(macro);
|
|
|
|
// macroContent.IsEmpty may be true, meaning the macro produces no output,
|
|
// but still can be cached because its execution did not trigger any error.
|
|
// so we need to actually render, only if macroContent is null
|
|
if (macroContent != null)
|
|
return macroContent;
|
|
|
|
// this will take care of errors
|
|
// it may throw, if we actually want to throw, so better not
|
|
// catch anything here and let the exception be thrown
|
|
var attempt = ExecuteMacroOfType(macro, content);
|
|
|
|
// by convention ExecuteMacroByType must either throw or return a result
|
|
// just check to avoid internal errors
|
|
macroContent = attempt.Result;
|
|
if (macroContent == null)
|
|
throw new Exception("Internal error, ExecuteMacroOfType returned no content.");
|
|
|
|
// add to cache if render is successful
|
|
// content may be empty but that's not an issue
|
|
if (attempt.Success)
|
|
{
|
|
// write to cache (if appropriate)
|
|
AddMacroContentToCache(macro, macroContent);
|
|
}
|
|
|
|
return macroContent;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes a macro of a given type.
|
|
/// </summary>
|
|
private Attempt<MacroContent> ExecuteMacroWithErrorWrapper(MacroModel macro, string msgIn, string msgOut, Func<MacroContent> getMacroContent, Func<string> msgErr)
|
|
{
|
|
using (_plogger.DebugDuration<MacroRenderer>(msgIn, msgOut))
|
|
{
|
|
return ExecuteProfileMacroWithErrorWrapper(macro, msgIn, getMacroContent, msgErr);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes a macro of a given type.
|
|
/// </summary>
|
|
private Attempt<MacroContent> ExecuteProfileMacroWithErrorWrapper(MacroModel macro, string msgIn, Func<MacroContent> getMacroContent, Func<string> msgErr)
|
|
{
|
|
try
|
|
{
|
|
return Attempt.Succeed(getMacroContent());
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_plogger.Warn<MacroRenderer>(e, "Failed {MsgIn}", msgIn);
|
|
|
|
var macroErrorEventArgs = new MacroErrorEventArgs
|
|
{
|
|
Name = macro.Name,
|
|
Alias = macro.Alias,
|
|
MacroSource = macro.MacroSource,
|
|
Exception = e,
|
|
Behaviour = _contentSection.MacroErrorBehaviour
|
|
};
|
|
|
|
switch (macroErrorEventArgs.Behaviour)
|
|
{
|
|
case MacroErrorBehaviour.Inline:
|
|
// do not throw, eat the exception, display the trace error message
|
|
return Attempt.Fail(new MacroContent { Text = msgErr() }, e);
|
|
case MacroErrorBehaviour.Silent:
|
|
// do not throw, eat the exception, do not display anything
|
|
return Attempt.Fail(new MacroContent { Text = string.Empty }, e);
|
|
case MacroErrorBehaviour.Content:
|
|
// do not throw, eat the exception, display the custom content
|
|
return Attempt.Fail(new MacroContent { Text = macroErrorEventArgs.Html ?? string.Empty }, e);
|
|
//case MacroErrorBehaviour.Throw:
|
|
default:
|
|
// see http://issues.umbraco.org/issue/U4-497 at the end
|
|
// throw the original exception
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes a macro.
|
|
/// </summary>
|
|
/// <remarks>Returns an attempt that is successful if the macro ran successfully. If the macro failed
|
|
/// to run properly, the attempt fails, though it may contain a content. But for instance that content
|
|
/// should not be cached. In that case the attempt may also contain an exception.</remarks>
|
|
private Attempt<MacroContent> ExecuteMacroOfType(MacroModel model, IPublishedContent content)
|
|
{
|
|
if (model == null) throw new ArgumentNullException(nameof(model));
|
|
|
|
// ensure that we are running against a published node (ie available in XML)
|
|
// that may not be the case if the macro is embedded in a RTE of an unpublished document
|
|
|
|
if (content == null)
|
|
return Attempt.Fail(new MacroContent { Text = "[macro failed (no content)]" });
|
|
|
|
var textService = _textService;
|
|
|
|
switch (model.MacroType)
|
|
{
|
|
case MacroTypes.PartialView:
|
|
return ExecuteMacroWithErrorWrapper(model,
|
|
$"Executing PartialView: MacroSource=\"{model.MacroSource}\".",
|
|
"Executed PartialView.",
|
|
() => ExecutePartialView(model, content),
|
|
() => textService.Localize("errors/macroErrorLoadingPartialView", new[] { model.MacroSource }));
|
|
|
|
default:
|
|
return ExecuteMacroWithErrorWrapper(model,
|
|
$"Execute macro with unsupported type \"{model.MacroType}\".",
|
|
"Executed.",
|
|
() => { throw new Exception("Unsupported macro type."); },
|
|
() => textService.Localize("errors/macroErrorUnsupportedType"));
|
|
}
|
|
}
|
|
|
|
|
|
#endregion
|
|
|
|
#region Execute engines
|
|
|
|
/// <summary>
|
|
/// Renders a PartialView Macro.
|
|
/// </summary>
|
|
/// <returns>The text output of the macro execution.</returns>
|
|
private MacroContent ExecutePartialView(MacroModel macro, IPublishedContent content)
|
|
{
|
|
var engine = new PartialViewMacroEngine();
|
|
return engine.Execute(macro, content);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Execution helpers
|
|
|
|
// parses attribute value looking for [@requestKey], [%sessionKey], [#pageElement], [$recursiveValue]
|
|
// supports fallbacks eg "[@requestKey],[%sessionKey],1234"
|
|
private string ParseAttribute(IDictionary pageElements, string attributeValue)
|
|
{
|
|
if (pageElements == null) throw new ArgumentNullException(nameof(pageElements));
|
|
|
|
// check for potential querystring/cookie variables
|
|
attributeValue = attributeValue.Trim();
|
|
if (attributeValue.StartsWith("[") == false)
|
|
return attributeValue;
|
|
|
|
var tokens = attributeValue.Split(',').Select(x => x.Trim()).ToArray();
|
|
|
|
// ensure we only process valid input ie each token must be [?x] and not eg a json array
|
|
// like [1,2,3] which we don't want to parse - however the last one can be a literal, so
|
|
// don't check on the last one which can be just anything - check all previous tokens
|
|
|
|
char[] validTypes = { '@', '%', '#', '$' };
|
|
if (tokens.Take(tokens.Length - 1).Any(x =>
|
|
x.Length < 4 // ie "[?x]".Length - too short
|
|
|| x[0] != '[' // starts with [
|
|
|| x[x.Length - 1] != ']' // ends with ]
|
|
|| validTypes.Contains(x[1]) == false))
|
|
{
|
|
return attributeValue;
|
|
}
|
|
|
|
var context = _umbracoContextAccessor.UmbracoContext.HttpContext;
|
|
|
|
foreach (var token in tokens)
|
|
{
|
|
var isToken = token.Length > 4 && token[0] == '[' && token[token.Length - 1] == ']' && validTypes.Contains(token[1]);
|
|
|
|
if (isToken == false)
|
|
{
|
|
// anything that is not a token is a value, use it
|
|
attributeValue = token;
|
|
break;
|
|
}
|
|
|
|
var type = token[1];
|
|
var name = token.Substring(2, token.Length - 3);
|
|
|
|
switch (type)
|
|
{
|
|
case '@':
|
|
attributeValue = context?.Request[name];
|
|
break;
|
|
case '%':
|
|
attributeValue = context?.Session[name]?.ToString();
|
|
if (string.IsNullOrEmpty(attributeValue))
|
|
attributeValue = context?.Request.GetCookieValue(name);
|
|
break;
|
|
case '#':
|
|
attributeValue = pageElements[name]?.ToString();
|
|
break;
|
|
case '$':
|
|
attributeValue = pageElements[name]?.ToString();
|
|
if (string.IsNullOrEmpty(attributeValue))
|
|
attributeValue = ParseAttributeOnParents(pageElements, name);
|
|
break;
|
|
}
|
|
|
|
attributeValue = attributeValue?.Trim();
|
|
if (string.IsNullOrEmpty(attributeValue) == false)
|
|
break; // got a value, use it
|
|
}
|
|
|
|
return attributeValue;
|
|
}
|
|
|
|
private string ParseAttributeOnParents(IDictionary pageElements, string name)
|
|
{
|
|
if (pageElements == null) throw new ArgumentNullException(nameof(pageElements));
|
|
// this was, and still is, an ugly piece of nonsense
|
|
|
|
var value = string.Empty;
|
|
var cache = _umbracoContextAccessor.UmbracoContext.ContentCache;
|
|
|
|
var splitpath = (string[])pageElements["splitpath"];
|
|
for (var i = splitpath.Length - 1; i > 0; i--) // at 0 we have root (-1)
|
|
{
|
|
var content = cache.GetById(int.Parse(splitpath[i]));
|
|
if (content == null) continue;
|
|
value = content.Value(name)?.ToString();
|
|
if (string.IsNullOrEmpty(value) == false) break;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
#endregion
|
|
|
|
}
|
|
|
|
}
|