Skip to content

Commit

Permalink
Add authorization support. Add support for dispatching hydro events f…
Browse files Browse the repository at this point in the history
…rom client side without additional road trip to the backend.
  • Loading branch information
kjeske committed Oct 26, 2023
1 parent 07b75a9 commit 074f1c8
Show file tree
Hide file tree
Showing 8 changed files with 2,223 additions and 23 deletions.
102 changes: 84 additions & 18 deletions src/HydroComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using Int32Converter = Hydro.Utils.Int32Converter;

namespace Hydro;

Expand All @@ -21,14 +23,21 @@ public abstract class HydroComponent : ViewComponent
private string _id;

private readonly ConcurrentDictionary<CacheKey, object> _requestCache = new();
private static readonly ConcurrentDictionary<CacheKey, object> PersistantCache = new();
private static readonly ConcurrentDictionary<CacheKey, object> PersistentCache = new();

private readonly List<HydroComponentEvent> _dispatchEvents = new();
private readonly HashSet<HydroEventSubscription> _subscriptions = new();

private static readonly MethodInfo InvokeActionMethod = typeof(HydroComponent).GetMethod(nameof(InvokeAction), BindingFlags.Static | BindingFlags.NonPublic);
private static readonly MethodInfo InvokeActionAsyncMethod = typeof(HydroComponent).GetMethod(nameof(InvokeActionAsync), BindingFlags.Static | BindingFlags.NonPublic);

private static readonly JsonSerializerSettings JsonSerializerSettings = new JsonSerializerSettings
{
Converters = new JsonConverter[] { new Int32Converter() }.ToList()
};

private static readonly ConcurrentDictionary<Type, IHydroAuthorizationFilter[]> ComponentAuthorizationAttributes = new();

/// <summary>
/// Provides indication if ModelState is valid
/// </summary>
Expand Down Expand Up @@ -79,7 +88,7 @@ public void Subscribe<TEvent>() =>
EventName = GetFullTypeName(typeof(TEvent)),
Action = (TEvent _) => { }
});

/// <summary>
/// Subscribes to a Hydro event
/// </summary>
Expand All @@ -90,11 +99,11 @@ public void Subscribe<TEvent>(Action<TEvent> action) =>
{
EventName = GetFullTypeName(typeof(TEvent)),
Action = action
});
});

private static string GetFullTypeName(Type type) =>
type.DeclaringType != null
? type.DeclaringType.Name + "+" + type.Name
? type.DeclaringType.Name + "+" + type.Name
: type.Name;

/// <summary>
Expand Down Expand Up @@ -171,7 +180,7 @@ public virtual void Render()
/// <param name="url">Destination URL</param>
public void Redirect(string url) =>
HttpContext.Response.HydroRedirect(url);

/// <summary>
/// Perform a redirect without page reload
/// </summary>
Expand All @@ -189,7 +198,7 @@ public void Location(string url, object payload = null) =>
/// <returns>Produced value</returns>
protected Cache<T> Cache<T>(Func<T> func, CacheLifetime lifetime = CacheLifetime.Request)
{
var cache = lifetime == CacheLifetime.Request ? _requestCache : PersistantCache;
var cache = lifetime == CacheLifetime.Request ? _requestCache : PersistentCache;

var cacheKey = new CacheKey(_id, func);
if (cache.TryGetValue(cacheKey, out var dic))
Expand Down Expand Up @@ -234,14 +243,19 @@ private async Task<string> RenderOnlineRootComponent(IPersistentState persistent

PopulateBaseModel(persistentState);
PopulateRequestModel();
if (!await AuthorizeAsync())
{
return string.Empty;
}

await TriggerMethod();
await TriggerEvent();
await RenderAsync();
PopulateDispatchers();

return await GenerateComponentHtml(componentId, persistentState);
}

private async Task<string> RenderOnlineNestedComponent(IPersistentState persistentState)
{
var componentId = GenerateComponentId(Key);
Expand All @@ -252,19 +266,29 @@ private async Task<string> RenderOnlineNestedComponent(IPersistentState persiste
return GetComponentPlaceholderTemplate(componentId);
}

if (!await AuthorizeAsync())
{
return string.Empty;
}

await MountAsync();
await RenderAsync();
return await GenerateComponentHtml(componentId, persistentState);
}

private static string GetComponentPlaceholderTemplate(string componentId) =>
$"<div id=\"{componentId}\" hydro></div>";
$"<div id=\"{componentId}\" hydro hydro-placeholder></div>";

private async Task<string> RenderStaticComponent(IPersistentState persistentState)
{
var componentId = GenerateComponentId(Key);
_id = componentId;

if (!await AuthorizeAsync())
{
return string.Empty;
}

await MountAsync();
await RenderAsync();

Expand Down Expand Up @@ -345,7 +369,7 @@ private void BindModel(IFormCollection formCollection)
public virtual void Bind(string property, object value)
{
}

private HtmlNode GetModelScript(HtmlDocument document, string id, IPersistentState persistentState)
{
var scriptNode = document.CreateElement("script");
Expand Down Expand Up @@ -436,9 +460,9 @@ private async Task TriggerMethod()

private IDictionary<string, object> GetParameters() =>
HttpContext.Request.Headers.TryGetValue(HydroConsts.RequestHeaders.Parameters, out var parameters)
? JsonConvert.DeserializeObject<Dictionary<string, object>>(parameters)
? JsonConvert.DeserializeObject<Dictionary<string, object>>(parameters, JsonSerializerSettings)
: new Dictionary<string, object>();

/// <summary>
/// Get the payload transferred from previous page's component
/// </summary>
Expand All @@ -461,9 +485,9 @@ private async Task TriggerEvent()
var parameters = methodInfo.GetParameters();
var parameterType = parameters.First().ParameterType;
var model = HttpContext.Items.TryGetValue(HydroConsts.ContextItems.EventData, out var eventModel) ? JsonConvert.DeserializeObject((string)eventModel, parameterType) : null;

var isAsync = typeof(Task).IsAssignableFrom(methodInfo.ReturnType);

if (isAsync)
{
var method = InvokeActionAsyncMethod.MakeGenericMethod(parameterType);
Expand All @@ -477,13 +501,13 @@ private async Task TriggerEvent()
}
}
}

private static void InvokeAction<T>(Delegate actionDelegate, T instance)
{
var action = actionDelegate as Action<T>;
action?.Invoke(instance);
}

private static Task InvokeActionAsync<T>(Delegate actionDelegate, T instance)
{
var action = actionDelegate as Func<T, Task>;
Expand Down Expand Up @@ -522,6 +546,7 @@ private async Task<string> GetComponentHtml()
await using var writer = new StringWriter();
var previousWriter = ViewComponentContext.ViewContext.Writer;
ViewComponentContext.ViewContext.Writer = writer;
ViewComponentContext.ViewContext.CheckBoxHiddenInputRenderMode = CheckBoxHiddenInputRenderMode.None;

var result = View(GetViewPath(), this);

Expand Down Expand Up @@ -569,12 +594,25 @@ private void ApplyObject<T>(T target, object source)
continue;
}

if (sourceProperty.PropertyType != targetProperty.PropertyType)
object sourceValue;

if (sourceProperty.PropertyType == targetProperty.PropertyType)
{
sourceValue = sourceProperty.GetValue(source);
}
else
{
throw new InvalidCastException($"Type mismatch in {sourceProperty.Name} parameter.");
try
{
var json = JsonConvert.SerializeObject(sourceProperty.GetValue(source));
sourceValue = JsonConvert.DeserializeObject(json, targetProperty.PropertyType);
}
catch
{
throw new InvalidCastException($"Type mismatch in {sourceProperty.Name} parameter.");
}
}

var sourceValue = sourceProperty.GetValue(source);
targetProperty.SetValue(target, sourceValue);
}
}
Expand Down Expand Up @@ -645,6 +683,34 @@ private static IEnumerable<ValidationResult> ExtractValidationResults(IEnumerabl
}
}

private async Task<bool> AuthorizeAsync()
{
var type = GetType();

if (!ComponentAuthorizationAttributes.ContainsKey(type))
{
ComponentAuthorizationAttributes.TryAdd(type, type.GetCustomAttributes(true)
.Where(attr => attr is IHydroAuthorizationFilter)
.Cast<IHydroAuthorizationFilter>()
.ToArray());
}

foreach (var authorizationFilter in ComponentAuthorizationAttributes[type])
{
if (!await authorizationFilter.AuthorizeAsync(HttpContext, this))
{
if (HttpContext.IsHydro(excludeBoosted: true))
{
HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
}

return false;
}
}

return true;
}

private static string Hash(string input) =>
$"W{Convert.ToHexString(MD5.HashData(Encoding.ASCII.GetBytes(input)))}";
}
17 changes: 17 additions & 0 deletions src/IHydroAuthorizationFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Http;

namespace Hydro;

/// <summary>
/// A filter that confirms component authorization
/// </summary>
public interface IHydroAuthorizationFilter
{
/// <summary>
/// Called early in the component pipeline to confirm request is authorized
/// </summary>
/// <param name="httpContext">HttpContext</param>
/// <param name="component">Hydro component instance</param>
/// <returns>Indication if the the operation is authorized</returns>
Task<bool> AuthorizeAsync(HttpContext httpContext, object component);
}
53 changes: 50 additions & 3 deletions src/Scripts/hydro.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
promiseFunc()
.catch(error => {
console.log(`Error: ${error}`);
// throw error;
})
);
return lastPromise;
Expand Down Expand Up @@ -118,7 +119,8 @@
clearTimeout(binding[url].timeout);
}

binding[url].formData.set(el.getAttribute('name'), el.value);
const value = el.tagName === "INPUT" && el.type === 'checkbox' ? el.checked : el.value;
binding[url].formData.set(el.getAttribute('name'), value);

return await new Promise(resolve => {
binding[url].timeout = setTimeout(async () => {
Expand Down Expand Up @@ -147,7 +149,7 @@
if (!document.contains(el)) {
return;
}

const component = el.closest("[hydro]");
const componentId = component.getAttribute("id");

Expand Down Expand Up @@ -219,7 +221,9 @@
Alpine.morph(component, responseData, {
updating: (from, to, childrenOnly, skip) => {
if (counter !== 0 && to.getAttribute && to.getAttribute("hydro") !== null && from.getAttribute && from.getAttribute("hydro") !== null) {
skip();
if (to.getAttribute("hydro-placeholder") !== null) {
skip();
}
}

if (from.tagName === "INPUT" && from.type === 'checkbox') {
Expand Down Expand Up @@ -339,6 +343,49 @@ document.addEventListener('alpine:init', () => {
});
});

Alpine.directive('hydro-dispatch', (el, {expression}, {effect, cleanup}) => {
effect(() => {
if (!document.contains(el)) {
return;
}

const component = window.Hydro.findComponent(el);

if (!component) {
throw new Error("Cannot find Hydro component");
}

const eventName = el.getAttribute('hydro-event') || 'click';

if (!component.element.parentElement) {
debugger;
}

const parentComponent = window.Hydro.findComponent(component.element.parentElement);

const trigger = JSON.parse(expression);

if (trigger.scope === 'parent' && !parentComponent) {
return;
}

const scope = trigger.scope === 'parent' ? parentComponent.id : 'global';

const eventHandler = async (event) => {
event.preventDefault();
const eventObj = new CustomEvent(`${scope}:${trigger.name}`, {detail: trigger.data});

setTimeout(() => {
document.dispatchEvent(eventObj);
}, 0)
};
el.addEventListener(eventName, eventHandler);
cleanup(() => {
el.removeEventListener(eventName, eventHandler);
});
});
});

Alpine.directive('hydro-bind', (el, {expression, modifiers}, {effect, cleanup}) => {
effect(() => {
const event = expression || "change";
Expand Down
3 changes: 2 additions & 1 deletion src/TagHelpers/HydroActionTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ public override void Process(TagHelperContext context, TagHelperOutput output)
}

var methodName = Method.Replace("Model.", string.Empty);
output.Attributes.Add("x-hydro-action", $"/hydro/{ViewContext.ViewData.Model.GetType().Name}/{methodName}".ToLower());
var modelType = ViewContext.ViewData.ModelMetadata.ContainerType ?? ViewContext.ViewData.Model.GetType();
output.Attributes.Add("x-hydro-action", $"/hydro/{modelType.Name}/{methodName}".ToLower());

if (Parameters.Any())
{
Expand Down
4 changes: 3 additions & 1 deletion src/TagHelpers/HydroComponentTagHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
Expand Down Expand Up @@ -29,6 +30,7 @@ public sealed class HydroComponentTagHelper : TagHelper
/// Hydro component's action to execute
/// </summary>
[HtmlAttributeName(NameAttribute)]
[AspMvcViewComponent]
public string Name { get; set; }

/// <summary>
Expand Down
Loading

0 comments on commit 074f1c8

Please sign in to comment.