diff --git a/Source/Csla.AspNetCore/Blazor/State/SessionManager.cs b/Source/Csla.AspNetCore/Blazor/State/SessionManager.cs index 4d2cfae47f..88442dcac2 100644 --- a/Source/Csla.AspNetCore/Blazor/State/SessionManager.cs +++ b/Source/Csla.AspNetCore/Blazor/State/SessionManager.cs @@ -6,7 +6,6 @@ // Manages all user session data //----------------------------------------------------------------------- -using System.Collections.Concurrent; using Csla.State; namespace Csla.Blazor.State @@ -16,23 +15,30 @@ namespace Csla.Blazor.State /// root DI container. /// /// - public class SessionManager(ISessionIdManager sessionIdManager) : ISessionManager + /// + public class SessionManager(ISessionIdManager sessionIdManager, ISessionStore sessionStore) : ISessionManager { - private readonly ConcurrentDictionary _sessions = []; private readonly ISessionIdManager _sessionIdManager = sessionIdManager; + private readonly ISessionStore _sessionStore = sessionStore; /// /// Gets the session data for the current user. /// public Session GetSession() { - Session result; var key = _sessionIdManager.GetSessionId(); - if (!_sessions.ContainsKey(key)) - _sessions.TryAdd(key, []); - result = _sessions[key]; - result.Touch(); - return result; + var session = _sessionStore.GetSession(key); + if (session == null) + { + session = []; + session.Touch(); + _sessionStore.CreateSession(key, session); + return session; + } + + session.Touch(); + _sessionStore.UpdateSession(key, session); + return session; } /// @@ -44,9 +50,19 @@ public void UpdateSession(Session newSession) { ArgumentNullException.ThrowIfNull(newSession); var key = _sessionIdManager.GetSessionId(); - var existingSession = _sessions[key]; + var existingSession = _sessionStore.GetSession(key)!; Replace(newSession, existingSession); existingSession.Touch(); + _sessionStore.UpdateSession(key, existingSession); + } + + /// + /// Remove all expired session data. + /// + /// Expiration duration + public void PurgeSessions(TimeSpan expiration) + { + _sessionStore.DeleteSessions(new SessionsFilter { Expiration = expiration }); } /// @@ -62,21 +78,6 @@ private static void Replace(Session newSession, Session oldSession) oldSession.Add(key, newSession[key]); } - /// - /// Remove all expired session data. - /// - /// Expiration duration - public void PurgeSessions(TimeSpan expiration) - { - var expirationTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - expiration.TotalSeconds; - List toRemove = []; - foreach (var session in _sessions) - if (session.Value.LastTouched < expirationTime) - toRemove.Add(session.Key); - foreach (var key in toRemove) - _sessions.TryRemove(key, out _); - } - // wasm client-side methods Task ISessionManager.RetrieveSession(TimeSpan timeout) => throw new NotImplementedException(); Session ISessionManager.GetCachedSession() => throw new NotImplementedException(); diff --git a/Source/Csla.Blazor/ConfigurationExtensions.cs b/Source/Csla.Blazor/ConfigurationExtensions.cs index cbbb288c07..e4b630d22b 100644 --- a/Source/Csla.Blazor/ConfigurationExtensions.cs +++ b/Source/Csla.Blazor/ConfigurationExtensions.cs @@ -6,6 +6,7 @@ // Implement extension methods for .NET Core configuration //----------------------------------------------------------------------- +using System.Diagnostics.CodeAnalysis; using Csla.Blazor; using Csla.Blazor.State; using Csla.Core; @@ -63,6 +64,7 @@ public static CslaOptions AddServerSideBlazor(this CslaOptions config, Action(); } @@ -105,5 +107,27 @@ public class BlazorServerConfigurationOptions /// Gets or sets the type of the ISessionIdManager service. /// public Type SessionIdManagerType { get; set; } = Type.GetType("Csla.Blazor.State.SessionIdManager, Csla.AspNetCore", true); + + /// + /// Gets or sets the type of the SessionStore. + /// +#if NET8_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + internal Type SessionStoreType { get; set; } = typeof(DefaultSessionStore); + + /// + /// Sets the type of the SessionStore. + /// + /// + public BlazorServerConfigurationOptions RegisterSessionStore< +#if NET8_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + T>() where T: ISessionStore + { + SessionStoreType = typeof(T); + return this; + } } } diff --git a/Source/Csla.Blazor/State/DefaultSessionStore.cs b/Source/Csla.Blazor/State/DefaultSessionStore.cs new file mode 100644 index 0000000000..7281328c7b --- /dev/null +++ b/Source/Csla.Blazor/State/DefaultSessionStore.cs @@ -0,0 +1,71 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Default implementation of session storage. +//----------------------------------------------------------------------- +#nullable enable + +using System.Collections.Concurrent; +using Csla.State; + +namespace Csla.Blazor.State +{ + /// + /// Default implementation of + /// + public class DefaultSessionStore : ISessionStore + { + private readonly ConcurrentDictionary _store = new(); + + /// + public Session? GetSession(string key) + { + _store.TryGetValue(key, out var item); + return item; + } + + /// + public void CreateSession(string key, Session session) + { + if (!_store.TryAdd(key, session)) + { + throw new Exception("Key already exists"); + } + } + + /// + public void UpdateSession(string key, Session session) + { + ArgumentNullException.ThrowIfNull(session); + _store[key] = session; + } + + /// + public void DeleteSession(string key) + { + _store.TryRemove(key, out _); + } + + /// + public void DeleteSessions(SessionsFilter filter) + { + filter.Validate(); + + var query = _store.AsQueryable(); + if (filter.Expiration.HasValue) + { + var expirationTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - filter.Expiration.Value.TotalSeconds; + query = query.Where(x => x.Value.LastTouched < expirationTime); + } + + var keys = query.Select(x => x.Key).ToArray(); + + foreach (var key in keys) + { + _store.TryRemove(key, out _); + } + } + } +} diff --git a/Source/Csla/State/ISessionStore.cs b/Source/Csla/State/ISessionStore.cs new file mode 100644 index 0000000000..6367839ada --- /dev/null +++ b/Source/Csla/State/ISessionStore.cs @@ -0,0 +1,50 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Manages session storage +//----------------------------------------------------------------------- +#nullable enable + +namespace Csla.State +{ + /// + /// Session store + /// + public interface ISessionStore + { + /// + /// Retrieves a session + /// + /// + /// The session for the given key, or default if not found + Session? GetSession(string key); + + /// + /// Creates a session + /// + /// + /// + void CreateSession(string key, Session session); + + /// + /// Updates a session + /// + /// + /// + void UpdateSession(string key, Session session); + + /// + /// Deletes a session + /// + /// + void DeleteSession(string key); + + /// + /// Deletes sessions based on the filter. + /// + /// + void DeleteSessions(SessionsFilter filter); + } +} diff --git a/Source/Csla/State/SessionsFilter.cs b/Source/Csla/State/SessionsFilter.cs new file mode 100644 index 0000000000..ff2094ea10 --- /dev/null +++ b/Source/Csla/State/SessionsFilter.cs @@ -0,0 +1,33 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Filter for querying session storage +//----------------------------------------------------------------------- +#nullable enable + +namespace Csla.State +{ + /// + /// Filter to query sessions + /// + public class SessionsFilter + { + /// + /// A timespan to filter sessions last touched after the expiration + /// + public TimeSpan? Expiration { get; set; } + + /// + /// Validates + /// + public void Validate() + { + if (!Expiration.HasValue) + { + throw new ArgumentNullException("Expiration is required."); + } + } + } +}