aboutsummaryrefslogtreecommitdiff
path: root/plugins
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2024-04-08 22:04:04 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2024-04-08 22:04:04 -0400
commita7cf7c8987b8847984629293d8eb27908f3de3dd (patch)
tree62dfb4864c45b683ba58ccc41d8277ce5f8ce911 /plugins
parent27b487b6d0befdb2197a58ceadb1f1ac2b337786 (diff)
Squashed commit of the following:
commit 44803e06d1aa45496c04127930aa8897272d42f6 Author: vnugent <public@vaughnnugent.com> Date: Mon Apr 8 21:41:38 2024 -0400 fix: dangling/expired session security check and cookie cleanup commit 1082bd146549a1aff47877bcd28e6be1ce0ef5e9 Author: vnugent <public@vaughnnugent.com> Date: Sat Mar 30 22:20:29 2024 -0400 feat(app): Add AppData client plugin and browser library updated commit ec9b42f4cacbeae8a0b4d96e48bd9e522b3a9145 Merge: 2a11454 27b487b Author: vnugent <public@vaughnnugent.com> Date: Sun Mar 24 21:16:05 2024 -0400 Merge branch 'master' into develop commit 2a114541a3bfddae887adaa98c1ed326b125d511 Author: vnugent <public@vaughnnugent.com> Date: Sun Mar 24 20:53:38 2024 -0400 refactor: pull apart session authorization for future dev commit f8aea6453ddb2d56c1ce2ecb6a9e67d1af523c2e Author: vnugent <public@vaughnnugent.com> Date: Thu Mar 21 14:33:21 2024 -0400 feat: Add optional svg base64 icons for social OAuth2 connections commit cc29bed99dc9e151315cce75e50d55dca306b532 Author: vnugent <public@vaughnnugent.com> Date: Sun Mar 10 21:58:27 2024 -0400 source tree project location updated
Diffstat (limited to 'plugins')
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md18
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/build.readme.md0
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/AppDataEntry.cs54
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/CacheStore.cs249
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Endpoints/WebEndpoint.cs203
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/HttpExtensions.cs75
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/IAppDataStore.cs38
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordDataCacheEntry.cs38
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordOpFlags.cs35
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/UserRecordData.cs28
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/PersistentStorageManager.cs75
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/DataRecord.cs59
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/SqlBackingStore.cs144
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/UserRecordDbContext.cs59
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj67
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs53
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs13
17 files changed, 1181 insertions, 27 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md
new file mode 100644
index 0000000..0702226
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md
@@ -0,0 +1,18 @@
+# VNLib.Plugins.Essentials.Accounts.AppData
+*An Essentials plugin that provides endpoints for web-application synchronized storage such as user preferences.*
+
+> [!WARNING]
+> This plugin is still in early development and is not yet ready for production use.
+
+## Builds
+Debug build w/ symbols & xml docs, release builds, NuGet packages, and individually packaged source code are available on my website (link below).
+
+## Docs and Guides
+Documentation, specifications, and setup guides are available on my website.
+
+[Docs and Articles](https://www.vaughnnugent.com/resources/software/articles?tags=docs,_VNLib.Plugins.Essentials.Accounts.AppData)
+[Builds and Source](https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials)
+[Nuget Feeds](https://www.vaughnnugent.com/resources/software/modules)
+
+## License
+Source files in for this project are licensed to you under the GNU Affero General Public License (or any later version). See the LICENSE files for more information. \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/build.readme.md b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/build.readme.md
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/build.readme.md
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/AppDataEntry.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/AppDataEntry.cs
new file mode 100644
index 0000000..d6d936f
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/AppDataEntry.cs
@@ -0,0 +1,54 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: AppDataEntry.cs
+*
+* AppDataEntry.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using VNLib.Utils.Logging;
+using VNLib.Plugins.Extensions.Loading.Routing;
+
+using VNLib.Plugins.Essentials.Accounts.AppData.Endpoints;
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData
+{
+ public sealed class AppDataEntry : PluginBase
+ {
+ ///<inheritdoc/>
+ public override string PluginName => "Essentials.AppData";
+
+ ///<inheritdoc/>
+ protected override void OnLoad()
+ {
+ this.Route<WebEndpoint>();
+ Log.Information("Plugin loaded");
+ }
+
+ ///<inheritdoc/>
+ protected override void OnUnLoad()
+ {
+ Log.Information("Plugin unloaded");
+ }
+
+ ///<inheritdoc/>
+ protected override void ProcessHostCommand(string cmd)
+ { }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/CacheStore.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/CacheStore.cs
new file mode 100644
index 0000000..95c1b5a
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/CacheStore.cs
@@ -0,0 +1,249 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: CacheStore.cs
+*
+* CacheStore.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Buffers;
+using System.Threading;
+using System.Threading.Tasks;
+
+using MemoryPack;
+
+using VNLib.Utils.Extensions;
+using VNLib.Utils.Logging;
+using VNLib.Hashing.Checksums;
+using VNLib.Data.Caching;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.VNCache;
+using VNLib.Plugins.Extensions.VNCache.DataModel;
+using VNLib.Plugins.Essentials.Accounts.AppData.Stores;
+using VNLib.Plugins.Essentials.Accounts.AppData.Model;
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData
+{
+
+ [ConfigurationName("record_cache")]
+ internal sealed class CacheStore : IAppDataStore
+ {
+ const string LogScope = "Record Cache";
+
+ private readonly IEntityCache<RecordDataCacheEntry> _cache;
+ private readonly PersistentStorageManager _backingStore;
+ private readonly ILogProvider _logger;
+ private readonly bool AlwaysObserverCacheUpdate;
+ private readonly TimeSpan CacheTTL;
+
+
+ public CacheStore(PluginBase plugin, IConfigScope config)
+ {
+ string cachePrefix = config.GetRequiredProperty("prefix", p => p.GetString()!);
+ CacheTTL = config.GetRequiredProperty("ttl", p => p.GetTimeSpan(TimeParseType.Seconds))!;
+ AlwaysObserverCacheUpdate = config.GetRequiredProperty("force_write_through", p => p.GetBoolean())!;
+ _logger = plugin.Log.CreateScope(LogScope);
+
+ //Load persistent storage manager
+ _backingStore = plugin.GetOrCreateSingleton<PersistentStorageManager>();
+
+ //Use memory pack for serialization
+ MpSerializer serializer = new();
+
+ /*
+ * Initialize entity cache from the default global cache provider,
+ * then create a prefixed cache for the app data records.
+ *
+ * The app should make sure that the cache provider is available
+ * otherwise do not load this component.
+ */
+ _cache = plugin.GetDefaultGlobalCache()
+ ?.GetPrefixedCache(cachePrefix)
+ ?.CreateEntityCache<RecordDataCacheEntry>(serializer, serializer)
+ ?? throw new InvalidOperationException("No cache provider is available");
+
+ _logger.Verbose("Cache and backing store initialized");
+ }
+
+ ///<inheritdoc/>
+ public Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation)
+ {
+ /*
+ * Deleting entires does not matter if they existed previously or not. Just
+ * that the opeation executed successfully.
+ *
+ * Parallelize the delete operation to the cache and the backing store
+ */
+ Task fromCache = _cache.RemoveAsync(GetCacheKey(userId, recordKey), cancellation);
+ Task fromDb = _backingStore.DeleteRecordAsync(userId, recordKey, cancellation);
+
+ return Task.WhenAll(fromCache, fromDb);
+ }
+
+ ///<inheritdoc/>
+ public async Task<UserRecordData?> GetRecordAsync(string userId, string recordKey, RecordOpFlags flags, CancellationToken cancellation)
+ {
+ bool useCache = (flags & RecordOpFlags.NoCache) == 0;
+
+ //See if caller wants to bypass cache
+ if (useCache)
+ {
+ string cacheKey = GetCacheKey(userId, recordKey);
+
+ //try fetching from cache
+ RecordDataCacheEntry? cached = await _cache.GetAsync(cacheKey, cancellation);
+
+ //if cache is valid, return it
+ if (cached != null && !IsCacheExpired(cached))
+ {
+ return new(userId, cached.RecordData, cached.UnixTimestamp, cached.Checksum);
+ }
+ }
+
+ //fetch from db
+ UserRecordData? stored = await _backingStore.GetRecordAsync(userId, recordKey, flags, cancellation);
+
+ //If the record is valid and cache is enabled, update the record in cache
+ if (useCache && stored is not null)
+ {
+ //If no checksum is present, calculate it before storing in cache
+ if (!stored.Checksum.HasValue)
+ {
+ ulong checksum = FNV1a.Compute64(stored.Data);
+ stored = stored with { Checksum = checksum };
+ }
+
+ //update cached version
+ Task update = DeferCacheUpdate(
+ userId,
+ recordKey,
+ stored.Data,
+ stored.LastModifed,
+ stored.Checksum.Value
+ );
+
+ if (AlwaysObserverCacheUpdate || (flags & RecordOpFlags.WriteThrough) != 0)
+ {
+ //Wait for cache update to complete
+ await update.ConfigureAwait(false);
+ }
+ else
+ {
+ //Defer the cache update and continue
+ WatchDeferredCacheUpdate(update);
+ }
+ }
+
+ return stored;
+ }
+
+ ///<inheritdoc/>
+ public Task SetRecordAsync(string userId, string recordKey, byte[] data, ulong checksum, RecordOpFlags flags, CancellationToken cancellation)
+ {
+
+ //Always push update to db
+ Task db = _backingStore.SetRecordAsync(userId, recordKey, data, checksum, flags, cancellation);
+
+ //Optionally push update to cache
+ Task cache = Task.CompletedTask;
+
+ if ((flags & RecordOpFlags.NoCache) == 0)
+ {
+ long time = DateTimeOffset.Now.ToUnixTimeSeconds();
+
+ //Push update to cache
+ cache = DeferCacheUpdate(userId, recordKey, data, time, checksum);
+ }
+
+ /*
+ * If writethough is not set, updates will always be deferred
+ * and this call will return immediately.
+ *
+ * We still need to observe the task incase an error occurs
+ */
+ Task all = Task.WhenAll(db, cache);
+
+ if (AlwaysObserverCacheUpdate || (flags & RecordOpFlags.WriteThrough) != 0)
+ {
+ return all;
+ }
+ else
+ {
+ WatchDeferredCacheUpdate(all);
+ return Task.CompletedTask;
+ }
+ }
+
+ private string GetCacheKey(string userId, string recordKey) => $"{userId}:{recordKey}";
+
+ private bool IsCacheExpired(RecordDataCacheEntry entry)
+ {
+ return DateTimeOffset.FromUnixTimeSeconds(entry.UnixTimestamp).Add(CacheTTL) < DateTimeOffset.Now;
+ }
+
+ private Task DeferCacheUpdate(string userId, string recordKey, byte[] data, long time, ulong checksum)
+ {
+ string cacheKey = GetCacheKey(userId, recordKey);
+
+ RecordDataCacheEntry entry = new()
+ {
+ Checksum = checksum,
+ RecordData = data,
+ UnixTimestamp = time
+ };
+
+ return _cache.UpsertAsync(cacheKey, entry);
+ }
+
+ private async void WatchDeferredCacheUpdate(Task update)
+ {
+ try
+ {
+ await update.ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.Warn(e, "Failed to update cached User AppData record");
+ }
+ else
+ {
+ _logger.Warn("Failed to update cached AppData record");
+ }
+ }
+ }
+
+
+ private sealed class MpSerializer : ICacheObjectDeserializer, ICacheObjectSerializer
+ {
+
+ public T? Deserialize<T>(ReadOnlySpan<byte> objectData)
+ {
+ return MemoryPackSerializer.Deserialize<T>(objectData);
+ }
+
+ public void Serialize<T>(T obj, IBufferWriter<byte> finiteWriter)
+ {
+ MemoryPackSerializer.Serialize(finiteWriter, obj);
+ }
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Endpoints/WebEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Endpoints/WebEndpoint.cs
new file mode 100644
index 0000000..9c8f501
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Endpoints/WebEndpoint.cs
@@ -0,0 +1,203 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: WebEndpoint.cs
+*
+* WebEndpoint.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Net;
+using System.Linq;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+using VNLib.Net.Http;
+using VNLib.Utils.Logging;
+using VNLib.Hashing.Checksums;
+using VNLib.Plugins.Essentials.Endpoints;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Extensions.VNCache;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Validation;
+
+using VNLib.Plugins.Essentials.Accounts.AppData.Model;
+using VNLib.Plugins.Essentials.Accounts.AppData.Stores;
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData.Endpoints
+{
+ [ConfigurationName("web_endpoint")]
+ internal sealed class WebEndpoint : ProtectedWebEndpoint
+ {
+ const int DefaultMaxDataSize = 8 * 1024;
+
+ private readonly IAppDataStore _store;
+ private readonly int MaxDataSize;
+ private readonly string[] AllowedScopes;
+
+ public WebEndpoint(PluginBase plugin, IConfigScope config)
+ {
+ string path = config.GetRequiredProperty("path", p => p.GetString())!;
+ InitPathAndLog(path, plugin.Log.CreateScope("Endpoint"));
+
+ MaxDataSize = config.GetValueOrDefault("max_data_size", p => p.GetInt32(), DefaultMaxDataSize);
+ AllowedScopes = config.GetRequiredProperty("allowed_scopes", p => p.EnumerateArray().Select(p => p.GetString()!)).ToArray();
+
+ bool useCache = false;
+
+ //Cache loading is optional
+ if (plugin.HasConfigForType<CacheStore>())
+ {
+ //See if caching is enabled
+ IConfigScope cacheConfig = plugin.GetConfigForType<CacheStore>();
+ useCache = cacheConfig.GetValueOrDefault("enabled", e => e.GetBoolean(), false);
+
+ if (useCache && plugin.GetDefaultGlobalCache() is null)
+ {
+ plugin.Log.Error("Cache was enabled but no caching library was loaded. Continuing without cache");
+ useCache = false;
+ }
+ }
+
+ _store = LoadStore(plugin, useCache);
+ }
+
+ private static IAppDataStore LoadStore(PluginBase plugin, bool withCache)
+ {
+ return withCache
+ ? plugin.GetOrCreateSingleton<CacheStore>()
+ : plugin.GetOrCreateSingleton<PersistentStorageManager>();
+ }
+
+ protected async override ValueTask<VfReturnType> GetAsync(HttpEntity entity)
+ {
+ WebMessage webm = new();
+
+ string? scopeId = entity.QueryArgs.GetValueOrDefault("scope");
+ bool noCache = entity.QueryArgs.ContainsKey("no_cache");
+
+ if (webm.Assert(scopeId != null, "Missing scope"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if (webm.Assert(AllowedScopes.Contains(scopeId), "Invalid scope"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ //If the connection has the no-cache header set, also bypass the cache
+ noCache |= entity.Server.NoCache();
+
+ //optionally bypass cache if the user requests it
+ RecordOpFlags flags = noCache ? RecordOpFlags.NoCache : RecordOpFlags.None;
+
+ UserRecordData? record = await _store.GetRecordAsync(entity.Session.UserID, scopeId, flags, entity.EventCancellation);
+
+ if (record is null)
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.NotFound);
+ }
+
+ //return the raw data with the checksum header
+ entity.SetRecordResponse(record, HttpStatusCode.OK);
+ return VfReturnType.VirtualSkip;
+ }
+
+ protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity)
+ {
+ WebMessage webm = new();
+ string? scopeId = entity.QueryArgs.GetValueOrDefault("scope");
+ bool flush = entity.QueryArgs.ContainsKey("flush");
+
+ if (webm.Assert(entity.Files.Count == 1, "Invalid file count"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if (webm.Assert(scopeId != null, "Missing scope"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if (webm.Assert(AllowedScopes.Contains(scopeId), "Invalid scope"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ FileUpload data = entity.Files[0];
+
+ if (webm.Assert(data.Length <= MaxDataSize, "Data too large"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.RequestEntityTooLarge);
+ }
+
+ byte[] recordData = new byte[data.Length];
+ int read = await data.FileData.ReadAsync(recordData, entity.EventCancellation);
+
+ if (webm.Assert(read == recordData.Length, "Failed to read data"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.InternalServerError);
+ }
+
+ //Compute checksum on sent data and compare to the header if it exists
+ ulong checksum = FNV1a.Compute64(recordData);
+ ulong? userChecksum = entity.Server.GetUserDataChecksum();
+
+ if (userChecksum.HasValue)
+ {
+ //compare the checksums
+ if (webm.Assert(checksum == userChecksum.Value, "Checksum mismatch"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
+ }
+ }
+
+ /*
+ * If the user specifies the flush flag, the call will wait until the entire record
+ * is published to the persistent store before returning. Typically if a caching layer is
+ * used, the record will be written to the cache and the call will return immediately.
+ */
+ RecordOpFlags flags = flush ? RecordOpFlags.WriteThrough : RecordOpFlags.None;
+
+ //Write the record to the store
+ await _store.SetRecordAsync(entity.Session.UserID, scopeId, recordData, checksum, flags, entity.EventCancellation);
+ return VirtualClose(entity, HttpStatusCode.Accepted);
+ }
+
+ protected override async ValueTask<VfReturnType> DeleteAsync(HttpEntity entity)
+ {
+ WebMessage webm = new();
+ string? scopeId = entity.QueryArgs.GetValueOrDefault("scope");
+
+ if (webm.Assert(scopeId != null, "Missing scope"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if (webm.Assert(AllowedScopes.Contains(scopeId), "Invalid scope"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ //Write the record to the store
+ await _store.DeleteRecordAsync(entity.Session.UserID, scopeId, entity.EventCancellation);
+ return VirtualClose(entity, HttpStatusCode.Accepted);
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/HttpExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/HttpExtensions.cs
new file mode 100644
index 0000000..9628b79
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/HttpExtensions.cs
@@ -0,0 +1,75 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: HttpExtensions.cs
+*
+* HttpExtensions.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Net;
+
+using VNLib.Net.Http;
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData.Model
+{
+ internal static class HttpExtensions
+ {
+ const string ChecksumHeader = "X-Data-Checksum";
+
+ public static void SetRecordResponse(this HttpEntity entity, UserRecordData record, HttpStatusCode code)
+ {
+ //Set checksum header
+ entity.Server.Headers.Append(ChecksumHeader, $"{record.Checksum}");
+
+ //Set the response to a new memory reader with the record data
+ entity.CloseResponse(
+ code,
+ ContentType.Binary,
+ new BinDataRecordReader(record.Data)
+ );
+ }
+
+ public static ulong? GetUserDataChecksum(this IConnectionInfo server)
+ {
+ string? checksumStr = server.Headers[ChecksumHeader];
+ return string.IsNullOrWhiteSpace(checksumStr) && ulong.TryParse(checksumStr, out ulong checksum) ? checksum : null;
+ }
+
+ sealed class BinDataRecordReader(byte[] recordData) : IMemoryResponseReader
+ {
+ private int _read;
+
+ ///<inheritdoc/>
+ public int Remaining => recordData.Length - _read;
+
+ ///<inheritdoc/>
+ public void Advance(int written) => _read += written;
+
+ ///<inheritdoc/>
+ public void Close()
+ {
+ //No-op
+ }
+
+ ///<inheritdoc/>
+ public ReadOnlyMemory<byte> GetMemory() => recordData.AsMemory(_read);
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/IAppDataStore.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/IAppDataStore.cs
new file mode 100644
index 0000000..5b38d21
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/IAppDataStore.cs
@@ -0,0 +1,38 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: IAppDataStore.cs
+*
+* IAppDataStore.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData.Model
+{
+ internal interface IAppDataStore
+ {
+ Task<UserRecordData?> GetRecordAsync(string userId, string recordKey, RecordOpFlags flags, CancellationToken cancellation);
+
+ Task SetRecordAsync(string userId, string recordKey, byte[] data, ulong checksum, RecordOpFlags flags, CancellationToken cancellation);
+
+ Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation);
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordDataCacheEntry.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordDataCacheEntry.cs
new file mode 100644
index 0000000..9c0767d
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordDataCacheEntry.cs
@@ -0,0 +1,38 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: RecordDataCacheEntry.cs
+*
+* RecordDataCacheEntry.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using MemoryPack;
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData.Model
+{
+ [MemoryPackable]
+ internal partial class RecordDataCacheEntry
+ {
+ public byte[] RecordData { get; set; }
+
+ public ulong? Checksum { get; set; }
+
+ public long UnixTimestamp { get; set; }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordOpFlags.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordOpFlags.cs
new file mode 100644
index 0000000..31d9840
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordOpFlags.cs
@@ -0,0 +1,35 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: RecordOpFlags.cs
+*
+* RecordOpFlags.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData.Model
+{
+ internal enum RecordOpFlags
+ {
+ None = 0,
+ IgnoreChecksum = 1,
+ WriteThrough = 2,
+ NoCache = 4,
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/UserRecordData.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/UserRecordData.cs
new file mode 100644
index 0000000..d3770c6
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/UserRecordData.cs
@@ -0,0 +1,28 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: UserRecordData.cs
+*
+* UserRecordData.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData.Model
+{
+ internal record class UserRecordData(string UserId, byte[] Data, long LastModifed, ulong? Checksum);
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/PersistentStorageManager.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/PersistentStorageManager.cs
new file mode 100644
index 0000000..99a3286
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/PersistentStorageManager.cs
@@ -0,0 +1,75 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: PersistentStorageManager.cs
+*
+* PersistentStorageManager.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+using VNLib.Utils.Logging;
+using VNLib.Plugins.Extensions.Loading;
+
+using VNLib.Plugins.Essentials.Accounts.AppData.Model;
+using VNLib.Plugins.Essentials.Accounts.AppData.Stores.Sql;
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData.Stores
+{
+ [ConfigurationName("storage")]
+ internal sealed class PersistentStorageManager : IAppDataStore
+ {
+ private readonly IAppDataStore _backingStore;
+
+ public PersistentStorageManager(PluginBase plugin, IConfigScope config)
+ {
+ string storeType = config.GetRequiredProperty("type", p => p.GetString()!).ToLower(null);
+
+ switch (storeType)
+ {
+ case "sql":
+ _backingStore = plugin.GetOrCreateSingleton<SqlBackingStore>();
+ plugin.Log.Information("Using SQL based backing store");
+ break;
+ default:
+ throw new NotSupportedException($"Storage type {storeType} is not supported");
+ }
+ }
+
+ ///<inheritdoc/>
+ public Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation)
+ {
+ return _backingStore.DeleteRecordAsync(userId, recordKey, cancellation);
+ }
+
+ ///<inheritdoc/>
+ public Task<UserRecordData?> GetRecordAsync(string userId, string recordKey, RecordOpFlags flags, CancellationToken cancellation)
+ {
+ return _backingStore.GetRecordAsync(userId, recordKey, flags, cancellation);
+ }
+
+ ///<inheritdoc/>
+ public Task SetRecordAsync(string userId, string recordKey, byte[] data, ulong checksum, RecordOpFlags flags, CancellationToken cancellation)
+ {
+ return _backingStore.SetRecordAsync(userId, recordKey, data, checksum, flags, cancellation);
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/DataRecord.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/DataRecord.cs
new file mode 100644
index 0000000..f3be974
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/DataRecord.cs
@@ -0,0 +1,59 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: DataRecord.cs
+*
+* DataRecord.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.ComponentModel.DataAnnotations;
+
+using VNLib.Plugins.Extensions.Data.Abstractions;
+using VNLib.Plugins.Extensions.Data;
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData.Stores.Sql
+{
+
+#nullable disable
+ internal sealed class DataRecord : DbModelBase, IUserEntity
+ {
+ [Key]
+ [MaxLength(64)]
+ public override string Id { get; set; }
+
+ [MaxLength(64)]
+ public string RecordKey { get; set; }
+
+ [MaxLength(64)]
+ public string UserId { get; set; }
+
+ public override DateTime Created { get; set; }
+
+ public override DateTime LastModified { get; set; }
+
+ [MaxLength(int.MaxValue)] //Should defailt to MAX it set to very large number
+ public byte[] Data { get; set; }
+
+ /// <summary>
+ /// The FNV-1a checksum of the data
+ /// </summary>
+ public long Checksum { get; set; }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/SqlBackingStore.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/SqlBackingStore.cs
new file mode 100644
index 0000000..f67c652
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/SqlBackingStore.cs
@@ -0,0 +1,144 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: SqlBackingStore.cs
+*
+* SqlBackingStore.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.EntityFrameworkCore;
+
+using VNLib.Utils.Logging;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Sql;
+using VNLib.Plugins.Extensions.Data;
+using VNLib.Plugins.Extensions.Data.Abstractions;
+using VNLib.Plugins.Extensions.Data.Extensions;
+using VNLib.Plugins.Essentials.Accounts.AppData.Model;
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData.Stores.Sql
+{
+
+ internal sealed class SqlBackingStore(PluginBase plugin) : IAppDataStore, IAsyncConfigurable
+ {
+ private readonly DbRecordStore _store = new(plugin.GetContextOptionsAsync());
+
+ ///<inheritdoc/>
+ async Task IAsyncConfigurable.ConfigureServiceAsync(PluginBase plugin)
+ {
+ //Wait for the options to be ready
+ await _store.WhenLoaded();
+
+ //Add startup delay
+ await Task.Delay(2000);
+
+ plugin.Log.Debug("Creating database tables for Account AppData");
+
+ await plugin.EnsureDbCreatedAsync<UserRecordDbContext>(plugin);
+ }
+
+ ///<inheritdoc/>
+ public Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation)
+ {
+ return _store.DeleteAsync([userId, recordKey], cancellation);
+ }
+
+ ///<inheritdoc/>
+ public async Task<UserRecordData?> GetRecordAsync(string userId, string recordKey, RecordOpFlags flags, CancellationToken cancellation)
+ {
+ DataRecord? dr = await _store.GetSingleAsync(userId, recordKey);
+
+ if (dr is null)
+ {
+ return null;
+ }
+
+ //get the last modified time in unix time for the caller
+ long lastModifed = new DateTimeOffset(dr.LastModified).ToUnixTimeSeconds();
+
+ return new(userId, dr.Data!, lastModifed, unchecked((ulong)dr.Checksum));
+ }
+
+ ///<inheritdoc/>
+ public Task SetRecordAsync(string userId, string recordKey, byte[] data, ulong checksum, RecordOpFlags flags, CancellationToken cancellation)
+ {
+ return _store.AddOrUpdateAsync(new DataRecord
+ {
+ UserId = userId,
+ RecordKey = recordKey,
+ Data = data,
+ Checksum = unchecked((long)checksum)
+ }, cancellation);
+ }
+
+ sealed class DbRecordStore(IAsyncLazy<DbContextOptions> options) : DbStore<DataRecord>
+ {
+ public async Task WhenLoaded() => await options;
+
+ ///<inheritdoc/>
+ public override IDbQueryLookup<DataRecord> QueryTable { get; } = new DbQueries();
+
+ ///<inheritdoc/>
+ public override IDbContextHandle GetNewContext() => new UserRecordDbContext(options.Value);
+
+ ///<inheritdoc/>
+ public override string GetNewRecordId() => Guid.NewGuid().ToString("N");
+
+ ///<inheritdoc/>
+ public override void OnRecordUpdate(DataRecord newRecord, DataRecord existing)
+ {
+ existing.Data = newRecord.Data;
+ existing.Checksum = newRecord.Checksum;
+ existing.RecordKey = newRecord.RecordKey;
+ existing.UserId = newRecord.UserId;
+ existing.Created = newRecord.Created;
+ }
+
+ sealed class DbQueries : IDbQueryLookup<DataRecord>
+ {
+ public IQueryable<DataRecord> GetCollectionQueryBuilder(IDbContextHandle context, params string[] constraints)
+ {
+ throw new NotSupportedException("Lists for users is not queryable. Callers must submit a record key");
+ }
+
+ public IQueryable<DataRecord> GetSingleQueryBuilder(IDbContextHandle context, params string[] constraints)
+ {
+ string userId = constraints[0];
+ string recordKey = constraints[1];
+
+ return from r in context.Set<DataRecord>()
+ where r.UserId == userId && r.RecordKey == recordKey
+ select r;
+ }
+
+ public IQueryable<DataRecord> AddOrUpdateQueryBuilder(IDbContextHandle context, DataRecord record)
+ {
+ return GetSingleQueryBuilder(context, record.UserId!, record.RecordKey!);
+ }
+ }
+ }
+
+
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/UserRecordDbContext.cs b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/UserRecordDbContext.cs
new file mode 100644
index 0000000..1ab8767
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/UserRecordDbContext.cs
@@ -0,0 +1,59 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts.AppData
+* File: UserRecordDbContext.cs
+*
+* UserRecordDbContext.cs is part of VNLib.Plugins.Essentials.Accounts.AppData which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using Microsoft.EntityFrameworkCore;
+
+using VNLib.Plugins.Extensions.Data;
+using VNLib.Plugins.Extensions.Loading.Sql;
+
+namespace VNLib.Plugins.Essentials.Accounts.AppData.Stores.Sql
+{
+ internal sealed class UserRecordDbContext : DBContextBase, IDbTableDefinition
+ {
+ public DbSet<DataRecord> UserDataRecords { get; set; }
+
+ public UserRecordDbContext(DbContextOptions options) : base(options)
+ { }
+
+ public UserRecordDbContext()
+ { }
+
+ public void OnDatabaseCreating(IDbContextBuilder builder, object? userState)
+ {
+ //Define the table for the data records
+ builder.DefineTable<DataRecord>(nameof(UserDataRecords), table =>
+ {
+ //Define table columns
+ table.WithColumn(p => p.Id).AllowNull(false);
+ table.WithColumn(p => p.Version).TimeStamp();
+ table.WithColumn(p => p.RecordKey).AllowNull(false);
+ table.WithColumn(p => p.UserId).AllowNull(false);
+ table.WithColumn(p => p.Created);
+ table.WithColumn(p => p.LastModified);
+ table.WithColumn(p => p.Data);
+ table.WithColumn(p => p.Checksum);
+ });
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj
new file mode 100644
index 0000000..580faba
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj
@@ -0,0 +1,67 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <Nullable>enable</Nullable>
+ <TargetFramework>net8.0</TargetFramework>
+ <RootNamespace>VNLib.Plugins.Essentials.Accounts.AppData</RootNamespace>
+ <AssemblyName>Essentials.AppData</AssemblyName>
+ <AnalysisLevel>latest-all</AnalysisLevel>
+ <NeutralLanguage>en-US</NeutralLanguage>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <!--Enable dynamic loading-->
+ <EnableDynamicLoading>true</EnableDynamicLoading>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <PackageId>VNLib.Plugins.Essentials.Accounts.AppData</PackageId>
+ <Authors>Vaughn Nugent</Authors>
+ <Company>Vaughn Nugent</Company>
+ <Product>Essentials Account-Based client data storage</Product>
+ <Copyright>Copyright © 2024 Vaughn Nugent</Copyright>
+ <PackageProjectUrl>https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials</PackageProjectUrl>
+ <RepositoryUrl>https://github.com/VnUgE/Plugins.Essentials/tree/master/plugins/VNLib.Plugins.Essentials.Accounts.AppData</RepositoryUrl>
+ <Description>An Essentials plugin that provides endpoints for web-application synchronized storage such as user preferences</Description>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <PackageReadmeFile>README.md</PackageReadmeFile>
+ <PackageLicenseFile>LICENSE</PackageLicenseFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <None Include="..\..\..\LICENSE">
+ <Pack>True</Pack>
+ <PackagePath>\</PackagePath>
+ <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ </None>
+ <None Include="..\README.md">
+ <Pack>True</Pack>
+ <PackagePath>\</PackagePath>
+ </None>
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="MemoryPack" Version="1.20.3" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\..\VNLib.Data.Caching\lib\VNLib.Plugins.Extensions.VNCache\src\VNLib.Plugins.Extensions.VNCache.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Data\src\VNLib.Plugins.Extensions.Data.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Loading.Sql\src\VNLib.Plugins.Extensions.Loading.Sql.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib.Plugins.Extensions\lib\VNLib.Plugins.Extensions.Validation\src\VNLib.Plugins.Extensions.Validation.csproj" />
+ </ItemGroup>
+
+
+ <Target Condition="'$(BuildingInsideVisualStudio)' == true" Name="PostBuild" AfterTargets="PostBuildEvent">
+ <Exec Command="start xcopy &quot;$(TargetDir)&quot; &quot;$(SolutionDir)devplugins\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+</Project>
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
index 2e0c259..4f8bcd3 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs
@@ -104,33 +104,40 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
{
//Expired
ExpireCookies(entity, true);
-
+
//Verbose because this is a normal occurance
if (_logger.IsEnabled(LogLevel.Verbose))
{
_logger.Verbose("Session {id} expired", session.SessionID[..8]);
}
}
- else
+ else if (session.IsNew)
{
- //See if the session might be elevated
- if (!ClientWebAuthManager.IsSessionElevated(in session))
- {
- //If the session stored a user-agent, make sure it matches the connection
- if (session.UserAgent != null && !session.UserAgent.Equals(entity.Server.UserAgent, StringComparison.Ordinal))
- {
- _logger.Debug("Denied authorized connection from {ip} because user-agent changed", entity.TrustedRemoteIp);
- return ValueTask.FromResult(FileProcessArgs.Deny);
- }
- }
-
- //If the session is new, or not supposed to be logged in, clear the login cookies if they were set
- if (session.IsNew || string.IsNullOrEmpty(session.Token))
+ //explicitly expire cookies on new sessions
+ ExpireCookies(entity, false);
+ }
+ //See if the session might be elevated
+ else if (ClientWebAuthManager.IsSessionElevated(in session))
+ {
+ //If the session stored a user-agent, make sure it matches the connection
+ if (session.UserAgent != null && !session.UserAgent.Equals(entity.Server.UserAgent, StringComparison.Ordinal))
{
- //Do not force clear cookies (saves bandwidth)
- ExpireCookies(entity, false);
+ _logger.Debug("Denied authorized connection from {ip} because user-agent changed", entity.TrustedRemoteIp);
+ return ValueTask.FromResult(FileProcessArgs.Deny);
}
}
+ else
+ {
+ /*
+ * Attempts to clear client cookies if the session is not elevated
+ * and the client may still have cookies set from a previous session
+ *
+ * Cookies are only sent if the client also sent login cookies to avoid
+ * sending cookies on every request
+ */
+ ExpireCookies(entity, false);
+ }
+
}
//Always continue otherwise
@@ -147,7 +154,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
if (session.Created.AddSeconds(_config.WebSessionValidForSeconds) < entity.RequestedTimeUtc)
{
//Invalidate the session, so its technically valid for this request, but will be cleared on this handle close cycle
- entity.Session.Invalidate();
+ session.Invalidate();
//Clear auth specifc cookies
_authManager.DestroyAuthorization(entity);
@@ -169,7 +176,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
ArgumentNullException.ThrowIfNull(clientInfo.PublicKey, nameof(clientInfo.PublicKey));
ArgumentNullException.ThrowIfNull(clientInfo.ClientId, nameof(clientInfo.ClientId));
- if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web)
+ if (!IsSessionStateValid(in entity.Session))
{
throw new ArgumentException("The session is no configured for authorization");
}
@@ -189,7 +196,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
IClientAuthorization IAccountSecurityProvider.ReAuthorizeClient(HttpEntity entity)
{
//Confirm session is configured
- if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web)
+ if (!IsSessionStateValid(in entity.Session))
{
throw new InvalidOperationException("The session is not configured for authorization");
}
@@ -219,7 +226,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
bool IAccountSecurityProvider.IsClientAuthorized(HttpEntity entity, AuthorzationCheckLevel level)
{
//Session must be loaded and not-new for an authorization to exist
- if(!entity.Session.IsSet || entity.Session.IsNew)
+ if(!IsSessionStateValid(in entity.Session))
{
return false;
}
@@ -249,7 +256,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
{
//Use the public key supplied by the csecinfo
return RsaClientDataEncryption.TryEncrypt(entity.PublicKey, data, outputBuffer);
- }
+ }
+
+ private static bool IsSessionStateValid(in SessionInfo session) => session.IsSet && !session.IsNew && session.SessionType == SessionType.Web;
#endregion
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs
index c4b0c26..2c2058d 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs
@@ -34,6 +34,7 @@
using System;
using System.Linq;
using System.Text.Json;
+using System.Diagnostics;
using VNLib.Hashing;
using VNLib.Hashing.IdentityUtility;
@@ -286,7 +287,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
}
//Get the client signature
- string? base32Sig = GetSigningKey(in entity.Session);
+ string? base32Sig = GetSigningKey(in entity.Session);
if (string.IsNullOrWhiteSpace(base32Sig))
{
@@ -352,11 +353,12 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
private bool VerifyConnectionOTPInternal(HttpEntity entity)
{
- //Get the token from the client header, the client should always sent this
- string? signedMessage = entity.Server.Headers[_config.TokenHeaderName];
+ Debug.Assert(IsSessionValid(in entity.Session), "Session was assumed to be valid for this call");
- //Make sure a session is loaded
- if (!entity.Session.IsSet || entity.Session.IsNew || string.IsNullOrWhiteSpace(signedMessage))
+ //Get the token from the client header, the client should always sent this
+ string? signedMessage = GetOTPHeaderValue(entity);
+
+ if (string.IsNullOrWhiteSpace(signedMessage))
{
return false;
}
@@ -540,6 +542,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider
=> string.IsNullOrWhiteSpace(GetLoginToken(in session)) == false;
private void SetPubkeyCookie(HttpEntity entity, string value) => _pubkeyCookie.SetCookie(entity, value);
+ private string? GetOTPHeaderValue(HttpEntity entity) => entity.Server.Headers[_config.TokenHeaderName];
private static void SetSigningKey(ref readonly SessionInfo session, string? value) => session[PUBLIC_KEY_SIG_KEY_ENTRY] = value!;
private static void SetLoginToken(ref readonly SessionInfo session, string? value) => session[LOGIN_TOKEN_ENTRY] = value!;