Skip to content

Commit

Permalink
Merge pull request #104 from hydrostack/cookie-store
Browse files Browse the repository at this point in the history
Cookie store
  • Loading branch information
kjeske authored Oct 20, 2024
2 parents 7e57605 + b2e418b commit f13725d
Show file tree
Hide file tree
Showing 5 changed files with 210 additions and 11 deletions.
1 change: 1 addition & 0 deletions docs/content/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default defineConfig({
{ text: 'Navigation', link: '/features/navigation' },
{ text: 'Authorization', link: '/features/authorization' },
{ text: 'Form validation', link: '/features/form-validation' },
{ text: 'Cookies', link: '/features/cookies' },
{ text: 'Long polling', link: '/features/long-polling' },
{ text: 'Errors handling', link: '/features/errors-handling' },
{ text: 'Anti-forgery token', link: '/features/xsrf-token' },
Expand Down
83 changes: 83 additions & 0 deletions docs/content/features/cookies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
outline: deep
---

# Cookies

Hydro components provide a simple way to work with cookies in your application. You can read, write, and delete cookies using the `CookieStorage` property on the `HydroComponent` class:

```c#
// ThemeSwitcher.cshtml.cs
public class ThemeSwitcher : HydroComponent
{
public string Theme { get; set; }

public override void Mount()
{
Theme = CookieStorage.Get<string>("theme", defaultValue: "light");
}

public void Switch(string theme)
{
Theme = theme;
CookieStorage.Set("theme", theme);
}
}
```

## Complex objects

You can also store complex objects in cookies. Hydro will serialize and deserialize them for you:

```c#
// UserSettings.cshtml.cs
public class UserSettings : HydroComponent
{
public UserSettingsStorage Storage { get; set; }

public override void Mount()
{
Storage = CookieStorage.Get("settings", defaultValue: new UserSettingsStorage());
}

public void SwitchTheme(string theme)
{
Storage.Theme = theme;
CookieStorage.Set("settings", Storage);
}

public class UserSettingsStorage
{
public string StartupPage { get; set; }
public string Theme { get; set; }
}
}
```

## Customizing cookies

Default expiration date is 30 days, but can be customized with expiration parameter:

```c#
CookieStorage.Set("theme", "light", expiration: TimeSpan.FromDays(7));
```

You can further customize the cookie settings by passing an instance of `CookieOptions` to the `Set` method:

```c#
CookieStorage.Set("theme", "light", encrypt: false, new CookieOptions { Secure = true });
```

## Encryption

It's possible to encrypt the cookie value by setting the `encryption` parameter to `true`:

```c#
CookieStorage.Set("theme", "light", encryption: true);
```

```c#
CookieStorage.Get<string>("theme", encryption: true);
```
5 changes: 4 additions & 1 deletion src/Configuration/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Hydro.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Hydro.Configuration;
Expand All @@ -20,6 +22,7 @@ public static IServiceCollection AddHydro(this IServiceCollection services, Acti
options?.Invoke(hydroOptions);
services.AddSingleton(hydroOptions);
services.TryAddSingleton<IPersistentState, PersistentState>();

return services;
}
}
30 changes: 20 additions & 10 deletions src/HydroComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Text;
using HtmlAgilityPack;
using Hydro.Configuration;
using Hydro.Services;
using Hydro.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -32,7 +33,9 @@ public abstract class HydroComponent : TagHelper, IViewContextAware
private string _componentId;
private bool _skipOutput;
private dynamic _viewBag;

private CookieStorage _cookieStorage;
private IPersistentState _persistentState;

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

Expand Down Expand Up @@ -222,6 +225,18 @@ public dynamic ViewBag
/// </summary>
public object Params { get; set; }

/// <summary>
/// Provides actions that can be executed on client side
/// </summary>
public HydroClientActions Client { get; private set; }

/// <summary>
/// Cookie storage
/// </summary>
[HtmlAttributeNotBound]
public CookieStorage CookieStorage =>
_cookieStorage ??= new CookieStorage(HttpContext, _persistentState);

/// <summary>
/// Implementation of ViewComponent's InvokeAsync method
/// </summary>
Expand All @@ -241,11 +256,11 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu
var urlHelperFactory = services.GetService<IUrlHelperFactory>();
Url = urlHelperFactory.GetUrlHelper(ViewContext);

var persistentState = services.GetService<IPersistentState>();
_persistentState = services.GetService<IPersistentState>();
_options = services.GetService<HydroOptions>();
_logger = services.GetService<ILogger<HydroComponent>>() ?? NullLogger<HydroComponent>.Instance;

if (persistentState == null || _options == null)
if (_persistentState == null || _options == null)
{
throw new ApplicationException("Hydro has not been initialized with UseHydro in the application startup.");
}
Expand All @@ -255,8 +270,8 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu
try
{
var componentHtml = HttpContext.IsHydro(excludeBoosted: true)
? await RenderOnlineComponent(persistentState)
: await RenderStaticComponent(persistentState);
? await RenderOnlineComponent(_persistentState)
: await RenderStaticComponent(_persistentState);

output.Content.SetHtmlContent(componentHtml);

Expand Down Expand Up @@ -418,11 +433,6 @@ public void Dispatch<TEvent>(string name, TEvent data, Scope scope = Scope.Paren
public void DispatchGlobal<TEvent>(TEvent data, string subject = null, bool asynchronous = false) =>
Dispatch(GetFullTypeName(typeof(TEvent)), data, Scope.Global, asynchronous, subject);

/// <summary>
/// Provides actions that can be executed on client side
/// </summary>
public HydroClientActions Client { get; private set; }

/// <summary>
/// Triggered once the component is mounted
/// </summary>
Expand Down
102 changes: 102 additions & 0 deletions src/Services/CookieStorage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;

namespace Hydro.Services;

/// <summary>
/// Provides a standard implementation for ICookieManager interface, allowing to store/read complex objects in cookies
/// </summary>
public class CookieStorage
{
private readonly HttpContext _httpContext;
private readonly IPersistentState _persistentState;

/// <summary>
/// Returns a value type or a specific class stored in cookies
/// </summary>
public T Get<T>(string key, bool encryption = false, T defaultValue = default)
{
try
{
_httpContext.Request.Cookies.TryGetValue(key, out var storage);

if (storage != null)
{
var json = encryption
? _persistentState.Unprotect(storage)
: storage;

return JsonConvert.DeserializeObject<T>(json);
}
}
catch
{
//ignored
}

return defaultValue;
}

/// <summary>
/// Default expiration time for cookies
/// </summary>
public static TimeSpan DefaultExpirationTime = TimeSpan.FromDays(30);

/// <summary>
/// Customizable default JsonSerializerSettings used for complex objects
/// </summary>
public static JsonSerializerSettings JsonSettings = new()
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
};

internal CookieStorage(HttpContext httpContext, IPersistentState persistentState)
{
_httpContext = httpContext;
_persistentState = persistentState;
}

/// <summary>
/// Stores provided `value` in cookies
/// </summary>
public void Set<T>(string key, T value, bool encryption = false, TimeSpan? expiration = null)
{
var options = new CookieOptions
{
MaxAge = expiration ?? DefaultExpirationTime,
};

Set(key, value, encryption, options);
}

/// <summary>
/// Stores in cookies a value type or a specific class with exact options to be used. Will also be encrypted if `secure` is enabled in options.
/// </summary>
public void Set<T>(string key, T value, bool encryption, CookieOptions options)
{
var response = _httpContext.Response;

if (value != null)
{
var serializedValue = JsonConvert.SerializeObject(value, JsonSettings);
var finalValue = encryption
? _persistentState.Protect(serializedValue)
: serializedValue;

response.Cookies.Append(key, finalValue, options);
}
else
{
response.Cookies.Delete(key);
}
}

/// <summary>
/// Deletes a cookie record
/// </summary>
public void Delete(string key)
{
var response = _httpContext.Response;
response.Cookies.Delete(key);
}
}

0 comments on commit f13725d

Please sign in to comment.