From 1082bd146549a1aff47877bcd28e6be1ce0ef5e9 Mon Sep 17 00:00:00 2001 From: vnugent Date: Sat, 30 Mar 2024 22:20:29 -0400 Subject: feat(app): Add AppData client plugin and browser library updated --- .../README.md | 15 ++ .../build.readme.md | 0 .../src/AppDataEntry.cs | 54 +++++ .../src/CacheStore.cs | 249 +++++++++++++++++++++ .../src/Endpoints/WebEndpoint.cs | 203 +++++++++++++++++ .../src/Model/HttpExtensions.cs | 75 +++++++ .../src/Model/IAppDataStore.cs | 38 ++++ .../src/Model/RecordDataCacheEntry.cs | 38 ++++ .../src/Model/RecordOpFlags.cs | 35 +++ .../src/Model/UserRecordData.cs | 28 +++ .../src/Stores/PersistentStorageManager.cs | 75 +++++++ .../src/Stores/Sql/DataRecord.cs | 59 +++++ .../src/Stores/Sql/SqlBackingStore.cs | 144 ++++++++++++ .../src/Stores/Sql/UserRecordDbContext.cs | 59 +++++ ...NLib.Plugins.Essentials.Accounts.AppData.csproj | 67 ++++++ .../src/SecurityProvider/AccountSecProvider.cs | 2 +- 16 files changed, 1140 insertions(+), 1 deletion(-) create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/build.readme.md create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/AppDataEntry.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/CacheStore.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Endpoints/WebEndpoint.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/HttpExtensions.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/IAppDataStore.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordDataCacheEntry.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/RecordOpFlags.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Model/UserRecordData.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/PersistentStorageManager.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/DataRecord.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/SqlBackingStore.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/Stores/Sql/UserRecordDbContext.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj (limited to 'plugins') 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..3e3dac7 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/README.md @@ -0,0 +1,15 @@ +# VNLib.Plugins.Essentials.Accounts.AppData +*An Essentials plugin that provides endpoints for web-application synchronized storage such as user preferences.* + +## 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 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 + { + /// + public override string PluginName => "Essentials.AppData"; + + /// + protected override void OnLoad() + { + this.Route(); + Log.Information("Plugin loaded"); + } + + /// + protected override void OnUnLoad() + { + Log.Information("Plugin unloaded"); + } + + /// + 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 _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(); + + //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(serializer, serializer) + ?? throw new InvalidOperationException("No cache provider is available"); + + _logger.Verbose("Cache and backing store initialized"); + } + + /// + 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); + } + + /// + public async Task 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; + } + + /// + 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(ReadOnlySpan objectData) + { + return MemoryPackSerializer.Deserialize(objectData); + } + + public void Serialize(T obj, IBufferWriter 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()) + { + //See if caching is enabled + IConfigScope cacheConfig = plugin.GetConfigForType(); + 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() + : plugin.GetOrCreateSingleton(); + } + + protected async override ValueTask 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 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 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; + + /// + public int Remaining => recordData.Length - _read; + + /// + public void Advance(int written) => _read += written; + + /// + public void Close() + { + //No-op + } + + /// + public ReadOnlyMemory 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 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(); + plugin.Log.Information("Using SQL based backing store"); + break; + default: + throw new NotSupportedException($"Storage type {storeType} is not supported"); + } + } + + /// + public Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation) + { + return _backingStore.DeleteRecordAsync(userId, recordKey, cancellation); + } + + /// + public Task GetRecordAsync(string userId, string recordKey, RecordOpFlags flags, CancellationToken cancellation) + { + return _backingStore.GetRecordAsync(userId, recordKey, flags, cancellation); + } + + /// + 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; } + + /// + /// The FNV-1a checksum of the data + /// + 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()); + + /// + 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(plugin); + } + + /// + public Task DeleteRecordAsync(string userId, string recordKey, CancellationToken cancellation) + { + return _store.DeleteAsync([userId, recordKey], cancellation); + } + + /// + public async Task 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)); + } + + /// + 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 options) : DbStore + { + public async Task WhenLoaded() => await options; + + /// + public override IDbQueryLookup QueryTable { get; } = new DbQueries(); + + /// + public override IDbContextHandle GetNewContext() => new UserRecordDbContext(options.Value); + + /// + public override string GetNewRecordId() => Guid.NewGuid().ToString("N"); + + /// + 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 + { + public IQueryable GetCollectionQueryBuilder(IDbContextHandle context, params string[] constraints) + { + throw new NotSupportedException("Lists for users is not queryable. Callers must submit a record key"); + } + + public IQueryable GetSingleQueryBuilder(IDbContextHandle context, params string[] constraints) + { + string userId = constraints[0]; + string recordKey = constraints[1]; + + return from r in context.Set() + where r.UserId == userId && r.RecordKey == recordKey + select r; + } + + public IQueryable 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 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(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..c86f114 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts.AppData/src/VNLib.Plugins.Essentials.Accounts.AppData.csproj @@ -0,0 +1,67 @@ + + + + enable + net8.0 + VNLib.Plugins.Essentials.Accounts.AppData + Essentials.AppData + latest-all + en-US + true + + true + + + + VNLib.Plugins.Essentials.Accounts.AppData + Vaughn Nugent + Vaughn Nugent + Essentials user accounts an web authentication plugin + Copyright © 2024 Vaughn Nugent + https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials + https://github.com/VnUgE/Plugins.Essentials/tree/master/plugins/VNLib.Plugins.Essentials.Accounts.AppData + An Essentials plugin that provides endpoints for web-application synchronized storage such as user preferences + + + + README.md + LICENSE + + + + + True + \ + Always + + + True + \ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs index 2e0c259..2fc2568 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs @@ -147,7 +147,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); -- cgit