diff options
author | vnugent <public@vaughnnugent.com> | 2024-01-20 23:49:29 -0500 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2024-01-20 23:49:29 -0500 |
commit | 6cb7da37824d02a1898d08d0f9495c77fde4dd1d (patch) | |
tree | 95e37ea3c20f416d6a205ee4ab050c307b18eafe /back-end |
inital commit
Diffstat (limited to 'back-end')
-rw-r--r-- | back-end/README.md | 16 | ||||
-rw-r--r-- | back-end/Taskfile.yaml | 48 | ||||
-rw-r--r-- | back-end/src/Cache/UserSettings.cs | 35 | ||||
-rw-r--r-- | back-end/src/Cache/UserSettingsStore.cs | 62 | ||||
-rw-r--r-- | back-end/src/Endpoints/BookmarkEndpoint.cs | 373 | ||||
-rw-r--r-- | back-end/src/Model/BookmarkEntry.cs | 93 | ||||
-rw-r--r-- | back-end/src/Model/BookmarkStore.cs | 140 | ||||
-rw-r--r-- | back-end/src/Model/BookmarkStoreConfig.cs | 42 | ||||
-rw-r--r-- | back-end/src/Model/SimpleBookmarkContext.cs | 78 | ||||
-rw-r--r-- | back-end/src/Model/UserSettingsDbStore.cs | 77 | ||||
-rw-r--r-- | back-end/src/Model/UserSettingsEntry.cs | 39 | ||||
-rw-r--r-- | back-end/src/SimpleBookmark.csproj | 50 | ||||
-rw-r--r-- | back-end/src/SimpleBookmark.json | 16 | ||||
-rw-r--r-- | back-end/src/SimpleBookmarkEntry.cs | 66 |
14 files changed, 1135 insertions, 0 deletions
diff --git a/back-end/README.md b/back-end/README.md new file mode 100644 index 0000000..062d9d2 --- /dev/null +++ b/back-end/README.md @@ -0,0 +1,16 @@ +# Simple Bookmark/Back-End + +*A VNLib.Plugins.Essentials plugin library that adds back-end functionality for Simple Bookmark* + +## 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,_simple-bookmark) +[Builds and Source](https://www.vaughnnugent.com/resources/software/modules/simple-bookmark) +[Nuget Feeds](https://www.vaughnnugent.com/resources/software/modules) + +## License +See the parent directory's [license](..\LICENSE) file for more information.
\ No newline at end of file diff --git a/back-end/Taskfile.yaml b/back-end/Taskfile.yaml new file mode 100644 index 0000000..fce8399 --- /dev/null +++ b/back-end/Taskfile.yaml @@ -0,0 +1,48 @@ + +#taskfile for building the back-end server plugin + +version: '3' + +vars: + DOTNET_BUILD_FLAGS: '/p:RunAnalyzersDuringBuild=false /p:BuildInParallel=true /p:MultiProcessorCompilation=true' + +tasks: + + build: + dir: '{{.USER_WORKING_DIR}}' + cmds: + #build project + - dotnet publish -c release {{.DOTNET_BUILD_FLAGS}} + + #postbuild to package artifaces into the archives for upload + postbuild_success: + dir: '{{.USER_WORKING_DIR}}' + vars: + #output directory for the build artifacts + OUT_DIR: 'bin/release/{{.TARGET_FRAMEWORK}}/publish' + + cmds: + #pack up source code + - task: packsource + + #copy license to output dir + - powershell -Command "cp '{{.MODULE_DIR}}/LICENSE.txt' -Destination '{{.OUT_DIR}}/LICENSE.txt'" + + #tar the plugin output and put it in the bin dir + - cd {{.OUT_DIR}} && tar -czvf '{{.USER_WORKING_DIR}}/bin/release.tgz' . + + packsource: + dir: '{{.USER_WORKING_DIR}}' + internal: true + cmds: + #copy source code to target + - powershell -Command "Get-ChildItem -Include *.cs,*.csproj -Recurse | Where { \$_.FullName -notlike '*\obj\*' -and \$_.FullName -notlike '*\bin\*' } | Resolve-Path -Relative | tar --files-from - -cvzf 'bin/src.tgz'" + + #clean hook + clean: + dir: '{{.USER_WORKING_DIR}}' + ignore_error: true + cmds: + - dotnet clean -c release + - powershell -Command "Remove-Item -Recurse bin" + - powershell -Command "Remove-Item -Recurse obj"
\ No newline at end of file diff --git a/back-end/src/Cache/UserSettings.cs b/back-end/src/Cache/UserSettings.cs new file mode 100644 index 0000000..b656f83 --- /dev/null +++ b/back-end/src/Cache/UserSettings.cs @@ -0,0 +1,35 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// This program 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. +// +// This program 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.Text.Json.Serialization; + +using MemoryPack; + +namespace SimpleBookmark.Cache +{ + [MemoryPackable] + internal sealed partial class UserSettings + { + [JsonPropertyName("limit")] + public uint PreferredLimit { get; set; } = 10; + + [JsonPropertyName("new_tab")] + public bool OpenInNewTab { get; set; } = true; + + [JsonPropertyName("dark_mode")] + public bool DarkMode { get; set; } = false; + } +} diff --git a/back-end/src/Cache/UserSettingsStore.cs b/back-end/src/Cache/UserSettingsStore.cs new file mode 100644 index 0000000..51d47ff --- /dev/null +++ b/back-end/src/Cache/UserSettingsStore.cs @@ -0,0 +1,62 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// This program 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. +// +// This program 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 MemoryPack; + +using VNLib.Plugins; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Data.Caching; +using VNLib.Plugins.Extensions.VNCache; +using VNLib.Plugins.Extensions.VNCache.DataModel; + +namespace SimpleBookmark.Cache +{ + [ConfigurationName("settings")] + internal sealed class UserSettingsStore + { + private readonly IEntityCache<UserSettings>? Cache; + + public UserSettingsStore(PluginBase plugin, IConfigScope config) + { + //try to get the global cache provider + IGlobalCacheProvider? cache = plugin.GetDefaultGlobalCache(); + if (cache != null) + { + MemPackCacheSerializer serializer = new(null); + + //Recover the cache prefix + string prefix = config.GetRequiredProperty("cache_prefix", p => p.GetString()!); + + //Create a prefixed cache, then create an entity cache for the user settings + Cache = cache.GetPrefixedCache(prefix) + .CreateEntityCache<UserSettings>(serializer, serializer); + } + + } + + private sealed class MemPackCacheSerializer(MemoryPackSerializerOptions? options) : ICacheObjectSerializer, ICacheObjectDeserializer + { + ///<inheritdoc/> + public T? Deserialize<T>(ReadOnlySpan<byte> objectData) => MemoryPackSerializer.Deserialize<T>(objectData, options); + + ///<inheritdoc/> + public void Serialize<T>(T obj, IBufferWriter<byte> finiteWriter) => MemoryPackSerializer.Serialize(finiteWriter, obj, options); + } + } +} diff --git a/back-end/src/Endpoints/BookmarkEndpoint.cs b/back-end/src/Endpoints/BookmarkEndpoint.cs new file mode 100644 index 0000000..b7825d6 --- /dev/null +++ b/back-end/src/Endpoints/BookmarkEndpoint.cs @@ -0,0 +1,373 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// This program 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. +// +// This program 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 System.Linq; +using System.Buffers; +using System.Text.Json; +using System.Collections; +using SimpleBookmark.Model; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +using FluentValidation; +using FluentValidation.Results; + +using Microsoft.EntityFrameworkCore; + +using VNLib.Utils; +using VNLib.Utils.Logging; +using VNLib.Plugins; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Sql; +using VNLib.Plugins.Extensions.Data.Extensions; +using VNLib.Plugins.Extensions.Validation; + +namespace SimpleBookmark.Endpoints +{ + + [ConfigurationName("bm_endpoint")] + internal sealed class BookmarkEndpoint : ProtectedWebEndpoint + { + private static readonly IValidator<BookmarkEntry> BmValidator = BookmarkEntry.GetValidator(); + + private readonly BookmarkStore Bookmarks; + private readonly BookmarkStoreConfig BmConfig; + + public BookmarkEndpoint(PluginBase plugin, IConfigScope config) + { + string? path = config.GetRequiredProperty("path", p => p.GetString()!); + InitPathAndLog(path, plugin.Log); + + //Init new bookmark store + IAsyncLazy<DbContextOptions> options = plugin.GetContextOptionsAsync(); + Bookmarks = new BookmarkStore(options); + + //Load config + BmConfig = config.GetRequiredProperty("config", p => p.Deserialize<BookmarkStoreConfig>()!); + } + + ///<inheritdoc/> + protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity) + { + if (!entity.Session.CanRead()) + { + WebMessage webm = new() + { + Result = "You do not have permissions to read records", + Success = false + }; + + return VirtualClose(entity, webm, HttpStatusCode.Forbidden); + } + + if (entity.QueryArgs.TryGetNonEmptyValue("id", out string? singleId)) + { + //Try to get single record for the current user + BookmarkEntry? single = await Bookmarks.GetSingleUserRecordAsync(singleId, entity.Session.UserID); + + //Return result + return single is null ? VfReturnType.NotFound : VirtualOkJson(entity, single); + } + + if (entity.QueryArgs.ContainsKey("getTags")) + { + //Try to get all tags for the current user + string[] allTags = await Bookmarks.GetAllTagsForUserAsync(entity.Session.UserID, entity.EventCancellation); + + //Return result + return VirtualOkJson(entity, allTags); + } + + //See if count query + if(entity.QueryArgs.TryGetNonEmptyValue("count", out string? countS)) + { + //Try to get count + long count = await Bookmarks.GetUserRecordCountAsync(entity.Session.UserID, entity.EventCancellation); + + WebMessage webm = new () + { + Result = count, + Success = true + }; + + //Return result + return VirtualOk(entity, webm); + } + + //Get query parameters + _ = entity.QueryArgs.TryGetNonEmptyValue("limit", out string? limitS); + _ = uint.TryParse(limitS, out uint limit); + //Clamp limit to max limit + limit = Math.Clamp(limit, BmConfig.DefaultLimit, BmConfig.MaxLimit); + + //try to parse offset + _ = entity.QueryArgs.TryGetNonEmptyValue("page", out string? offsetS); + _ = uint.TryParse(offsetS, out uint offset); + + //Get any query arguments + if(entity.QueryArgs.TryGetNonEmptyValue("q", out string? query)) + { + //Replace percent encoding with spaces + query = query.Replace('+', ' '); + } + + string[] tags = Array.Empty<string>(); + + //Get tags + if (entity.QueryArgs.TryGetNonEmptyValue("t", out string? tagsS)) + { + //Split tags at spaces and remove empty entries + tags = tagsS.Split('+') + .Where(static t => !string.IsNullOrWhiteSpace(t)) + .ToArray(); + } + + //Get bookmarks + BookmarkEntry[] bookmarks = await Bookmarks.SearchBookmarksAsync( + entity.Session.UserID, + query, + tags, + (int)limit, + (int)offset, + entity.EventCancellation + ); + + //Return result + return VirtualOkJson(entity, bookmarks); + } + + ///<inheritdoc/> + protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + if (webm.Assert(entity.Session.CanWrite(), "You do not have permissions to create records")) + { + return VirtualClose(entity, webm, HttpStatusCode.Forbidden); + } + + //try to get the update from the request body + BookmarkEntry? newBookmark = await entity.GetJsonFromFileAsync<BookmarkEntry>(); + + if (webm.Assert(newBookmark != null, "No data was provided")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + //Remove any user id from the update + newBookmark.UserId = null; + + if (!BmValidator.Validate(newBookmark, webm)) + { + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); + } + + //See if the uses has reached their quota + long count = await Bookmarks.GetUserRecordCountAsync(entity.Session.UserID, entity.EventCancellation); + + if(webm.Assert(count <= BmConfig.PerPersonQuota, "You have reached your bookmark quota")) + { + return VirtualClose(entity, webm, HttpStatusCode.OK); + } + + //Try to create the record + ERRNO result = await Bookmarks.CreateUserRecordAsync(newBookmark, entity.Session.UserID, entity.EventCancellation); + + if (webm.Assert(result > 0, "Failed to create new bookmark")) + { + return VirtualClose(entity, webm, HttpStatusCode.OK); + } + + webm.Result = "Successfully created bookmark"; + webm.Success = true; + + return VirtualClose(entity, webm, HttpStatusCode.Created); + } + + ///<inheritdoc/> + protected override async ValueTask<VfReturnType> PatchAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + if (webm.Assert(entity.Session.CanWrite(), "You do not have permissions to update records")) + { + return VirtualClose(entity, webm, HttpStatusCode.Forbidden); + } + + //try to get the update from the request body + BookmarkEntry? update = await entity.GetJsonFromFileAsync<BookmarkEntry>(); + + if (webm.Assert(update != null, "No data was provided")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + if (webm.Assert(update!.Id != null, "The bookmark object is malformatted for this request")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + //Remove any user id from the update + update.UserId = null; + + if (!BmValidator.Validate(update, webm)) + { + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); + } + + //Try to update the record + ERRNO result = await Bookmarks.UpdateUserRecordAsync(update, entity.Session.UserID, entity.EventCancellation); + + if (webm.Assert(result > 0, "Failed to update existing record")) + { + return VirtualClose(entity, webm, HttpStatusCode.NotFound); + } + + webm.Result = "Successfully updated bookmark"; + webm.Success = true; + + return VirtualClose(entity, webm, HttpStatusCode.OK); + } + + /* + * PUT method is only used for bulk uploads + */ + + ///<inheritdoc/> + protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + if (webm.Assert(entity.Session.CanWrite(), "You do not have permissions to update records")) + { + return VirtualClose(entity, webm, HttpStatusCode.Forbidden); + } + + //See if the user wants to fail on invalid records + bool failOnInvalid = entity.QueryArgs.ContainsKey("failOnInvalid"); + + //try to get the update from the request body + BookmarkEntry[]? batch = await entity.GetJsonFromFileAsync<BookmarkEntry[]>(); + + if (webm.Assert(batch != null, "No data was provided")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + //filter out any null entries + IEnumerable<BookmarkEntry> sanitized = batch.Where(static b => b != null); + + if (failOnInvalid) + { + //Get any invalid entires and create a validation result + BookmarkError[] invalidEntires = sanitized.Select(b => + { + ValidationResult result = BmValidator.Validate(b); + if(result.IsValid) + { + return null; + } + + return new BookmarkError() + { + Errors = result.GetErrorsAsCollection(), + Subject = b + }; + + }) + .Where(static b => b != null) + .ToArray()!; + + //At least one error + if(invalidEntires.Length > 0) + { + //Notify the user of the invalid entires + BatchUploadResult res = new() + { + Errors = invalidEntires + }; + + webm.Result = res; + return VirtualOk(entity, webm); + } + } + else + { + //Remove any invalid entires + sanitized = sanitized.Where(static b => BmValidator.Validate(b).IsValid); + } + + //Try to update the records + ERRNO result = await Bookmarks.AddBulkAsync(sanitized, entity.Session.UserID, false, entity.EventCancellation); + + webm.Result = $"Successfully added {result} of {batch.Length} bookmarks"; + webm.Success = true; + + return VirtualClose(entity, webm, HttpStatusCode.OK); + } + + ///<inheritdoc/> + protected override async ValueTask<VfReturnType> DeleteAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + if (webm.Assert(entity.Session.CanDelete(), "You do not have permissions to delete records")) + { + return VirtualClose(entity, webm, HttpStatusCode.Forbidden); + } + + if(!entity.QueryArgs.TryGetNonEmptyValue("id", out string? deleteId)) + { + webm.Result = "No id was provided"; + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + //Try to delete the record + ERRNO result = await Bookmarks.DeleteUserRecordAsync(deleteId, entity.Session.UserID); + + if (webm.Assert(result > 0, "Requested bookmark does not exist")) + { + return VirtualClose(entity, webm, HttpStatusCode.NotFound); + } + + webm.Result = "Successfully deleted bookmark"; + webm.Success = true; + + return VirtualClose(entity, webm, HttpStatusCode.OK); + } + + sealed class BatchUploadResult + { + [JsonPropertyName("invalid")] + public BookmarkError[]? Errors { get; set; } + } + + sealed class BookmarkError + { + [JsonPropertyName("errors")] + public ICollection? Errors { get; set; } + + [JsonPropertyName("subject")] + public BookmarkEntry? Subject { get; set; } + } + } +} diff --git a/back-end/src/Model/BookmarkEntry.cs b/back-end/src/Model/BookmarkEntry.cs new file mode 100644 index 0000000..dca7f87 --- /dev/null +++ b/back-end/src/Model/BookmarkEntry.cs @@ -0,0 +1,93 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// This program 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. +// +// This program 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.Text.RegularExpressions; +using System.Text.Json.Serialization; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using FluentValidation; + +using Microsoft.EntityFrameworkCore; + +using VNLib.Plugins.Extensions.Data; +using VNLib.Plugins.Extensions.Data.Abstractions; + +namespace SimpleBookmark.Model +{ + [Index(nameof(Url))] + internal sealed partial class BookmarkEntry : DbModelBase, IUserEntity + { + [Key] + public override string Id { get; set; } + + public override DateTime Created { get; set; } + + public override DateTime LastModified { get; set; } + + [JsonIgnore] + public string? UserId { get; set; } + + [MaxLength(100)] + public string? Name { get; set; } + + [MaxLength(200)] + public string? Url { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + //Json flavor + [NotMapped] + [JsonPropertyName("Tags")] + public string[]? JsonTags + { + get => Tags?.Split(','); + set => Tags = value is null ? null : string.Join(',', value); + } + + //Database flavor as string + [JsonIgnore] + [MaxLength(100)] + public string? Tags { get; set; } + + public static IValidator<BookmarkEntry> GetValidator() + { + InlineValidator<BookmarkEntry> validator = new(); + + validator.RuleFor(p => p.Name) + .NotEmpty() + .Matches(@"^[a-zA-Z0-9_\-\|\. ]+$", RegexOptions.Compiled) + .MaximumLength(100); + + validator.RuleFor(p => p.Url) + .NotEmpty() + .Matches(@"^https?://", RegexOptions.Compiled) + .MaximumLength(200); + + validator.RuleFor(p => p.Description) + .MaximumLength(500); + + //Tags must be non-empty and alphanumeric only, no spaces + validator.RuleForEach(p => p.JsonTags) + .NotNull() + .NotEmpty() + .Matches(@"^[a-zA-Z0-9]+$", RegexOptions.Compiled); + + return validator; + } + } +} diff --git a/back-end/src/Model/BookmarkStore.cs b/back-end/src/Model/BookmarkStore.cs new file mode 100644 index 0000000..fbd3213 --- /dev/null +++ b/back-end/src/Model/BookmarkStore.cs @@ -0,0 +1,140 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// This program 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. +// +// This program 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 System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using VNLib.Plugins.Extensions.Data; +using VNLib.Plugins.Extensions.Data.Abstractions; +using VNLib.Plugins.Extensions.Loading; + +namespace SimpleBookmark.Model +{ + internal sealed class BookmarkStore(IAsyncLazy<DbContextOptions> dbOptions) : DbStore<BookmarkEntry> + { + ///<inheritdoc/> + public override IDbQueryLookup<BookmarkEntry> QueryTable { get; } = new BookmarkQueryLookup(); + + ///<inheritdoc/> + public override IDbContextHandle GetNewContext() => new SimpleBookmarkContext(dbOptions.Value); + + ///<inheritdoc/> + public override string GetNewRecordId() => Guid.NewGuid().ToString("n"); + + ///<inheritdoc/> + public override void OnRecordUpdate(BookmarkEntry newRecord, BookmarkEntry existing) + { + //Update existing record + existing.Name = newRecord.Name; + existing.Url = newRecord.Url; + existing.Description = newRecord.Description; + existing.JsonTags = newRecord.JsonTags; + } + + public async Task<BookmarkEntry[]> SearchBookmarksAsync(string userId, string? query, string[] tags, int limit, int page, CancellationToken cancellation) + { + ArgumentNullException.ThrowIfNull(userId); + ArgumentNullException.ThrowIfNull(tags); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(limit, 0); + ArgumentOutOfRangeException.ThrowIfNegative(page); + + //Init new db connection + await using SimpleBookmarkContext context = new(dbOptions.Value); + await context.OpenTransactionAsync(cancellation); + + //Start with userid + IQueryable<BookmarkEntry> q = context.Bookmarks.Where(b => b.UserId == userId); + + if (tags.Length > 0) + { + //if tags are set, only return bookmarks that match the tags + q = q.Where(b => b.Tags != null && tags.All(t => b.Tags!.Contains(t))); + } + + if (!string.IsNullOrWhiteSpace(query)) + { + //if query is set, only return bookmarks that match the query + q = q.Where(b => EF.Functions.Like(b.Name, $"%{query}%") || EF.Functions.Like(b.Description, $"%{query}%")); + } + + //return bookmarks in descending order of creation + q = q.OrderByDescending(static b => b.Created); + + //return only the requested page + q = q.Skip(page * limit).Take(limit); + + //execute query + BookmarkEntry[] results = await q.ToArrayAsync(cancellation); + + //Close db and commit transaction + await context.SaveAndCloseAsync(true, cancellation); + + return results; + } + + public async Task<string[]> GetAllTagsForUserAsync(string userId, CancellationToken cancellation) + { + ArgumentNullException.ThrowIfNull(userId); + + //Init new db connection + await using SimpleBookmarkContext context = new(dbOptions.Value); + await context.OpenTransactionAsync(cancellation); + + //Get all tags for the user + string[] tags = await context.Bookmarks + .Where(b => b.UserId == userId) + .Select(static b => b.Tags!) + .ToArrayAsync(cancellation); + + //Close db and commit transaction + await context.SaveAndCloseAsync(true, cancellation); + + //Split tags into individual strings + return tags + .Where(static t => !string.IsNullOrWhiteSpace(t)) + .SelectMany(static t => t!.Split(',')) + .Distinct() + .ToArray(); + } + + private sealed class BookmarkQueryLookup : IDbQueryLookup<BookmarkEntry> + { + public IQueryable<BookmarkEntry> GetCollectionQueryBuilder(IDbContextHandle context, params string[] constraints) + { + string userId = constraints[0]; + + return from b in context.Set<BookmarkEntry>() + where b.UserId == userId + orderby b.Created descending + select b; + } + + public IQueryable<BookmarkEntry> GetSingleQueryBuilder(IDbContextHandle context, params string[] constraints) + { + string bookmarkId = constraints[0]; + string userId = constraints[1]; + + + return from b in context.Set<BookmarkEntry>() + where b.UserId == userId && b.Id == bookmarkId + select b; + } + } + } +} diff --git a/back-end/src/Model/BookmarkStoreConfig.cs b/back-end/src/Model/BookmarkStoreConfig.cs new file mode 100644 index 0000000..95d835a --- /dev/null +++ b/back-end/src/Model/BookmarkStoreConfig.cs @@ -0,0 +1,42 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// This program 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. +// +// This program 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.Text.Json.Serialization; + + +namespace SimpleBookmark.Model +{ + internal sealed class BookmarkStoreConfig + { + /// <summary> + /// The maximum number of results that can be returned + /// in a single query. + /// </summary> + [JsonPropertyName("max_limit")] + public uint MaxLimit { get; set; } = 100; + + /// <summary> + /// The default number of results that will be returned in a single query. + /// </summary> + [JsonPropertyName("default_limit")] + public uint DefaultLimit { get; set; } = 10; + + /// <summary> + /// The maximum number of bookmarks that can be stored per user account. + /// </summary> + [JsonPropertyName("user_quota")] + public uint PerPersonQuota { get; set; } = 5000; + } +} diff --git a/back-end/src/Model/SimpleBookmarkContext.cs b/back-end/src/Model/SimpleBookmarkContext.cs new file mode 100644 index 0000000..ed734b3 --- /dev/null +++ b/back-end/src/Model/SimpleBookmarkContext.cs @@ -0,0 +1,78 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// This program 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. +// +// This program 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 SimpleBookmark.Model +{ + + internal sealed class SimpleBookmarkContext : TransactionalDbContext, IDbTableDefinition + { + + public DbSet<BookmarkEntry> Bookmarks { get; set; } + + public DbSet<UserSettingsEntry> BmSettings { get; set; } + + public SimpleBookmarkContext(DbContextOptions options) : base(options) + { } + + public SimpleBookmarkContext() : base() + { } + + public void OnDatabaseCreating(IDbContextBuilder builder, object? userState) + { + builder.DefineTable<BookmarkEntry>(nameof(Bookmarks)) + .WithColumn(p => p.Id) + .SetIsKey() + .Next() + + .WithColumn(p => p.Created) + .AllowNull(false) + .Next() + + .WithColumn(p => p.LastModified) + .AllowNull(false) + .Next() + + .WithColumn(p => p.UserId) + .AllowNull(false) + .Next() + + .WithColumn(p => p.Name) + .AllowNull(true) + .MaxLength(100) + .Next() + + .WithColumn(p => p.Url) + .AllowNull(true) + .Next() + + .WithColumn(p => p.Description) + .AllowNull(true) + .MaxLength(500) + .Next() + + .WithColumn(p => p.Tags) + .AllowNull(true) + .MaxLength(100) + .Next(); + } + + } +} diff --git a/back-end/src/Model/UserSettingsDbStore.cs b/back-end/src/Model/UserSettingsDbStore.cs new file mode 100644 index 0000000..aa44fa2 --- /dev/null +++ b/back-end/src/Model/UserSettingsDbStore.cs @@ -0,0 +1,77 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// This program 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. +// +// This program 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 Microsoft.EntityFrameworkCore; + +using VNLib.Utils; +using VNLib.Plugins.Extensions.Loading; + +namespace SimpleBookmark.Model +{ + internal sealed class UserSettingsDbStore(IAsyncLazy<DbContextOptions> dbOptions) + { + + public async Task<UserSettingsEntry?> GetSettingsForUserAsync(string userId, CancellationToken cancellation) + { + ArgumentNullException.ThrowIfNull(userId); + + //Init new db connection + await using SimpleBookmarkContext context = new(dbOptions.Value); + await context.OpenTransactionAsync(cancellation); + + UserSettingsEntry? settings = await context.BmSettings.FirstOrDefaultAsync(p => p.UserId == userId, cancellation); + + //Close db and commit transaction + await context.SaveAndCloseAsync(true, cancellation); + + return settings; + } + + public async Task<ERRNO> SetSettingsForUser(string userId, UserSettingsEntry settings, CancellationToken cancellation) + { + ArgumentNullException.ThrowIfNull(userId); + ArgumentNullException.ThrowIfNull(settings); + + //Init new db connection + await using SimpleBookmarkContext context = new(dbOptions.Value); + await context.OpenTransactionAsync(cancellation); + + //Search for existing settings entry + UserSettingsEntry? existing = await context.BmSettings.FirstOrDefaultAsync(p => p.UserId == userId, cancellation); + + if (existing is null) + { + //Add a new entry + settings.UserId = userId; + settings.LastModified = DateTime.UtcNow; + context.Add(settings); + } + else + { + //Update existing entry + existing.SettingsData = settings.SettingsData; + existing.LastModified = DateTime.UtcNow; + context.Update(existing); + } + + //Close db and commit transaction + return await context.SaveAndCloseAsync(true, cancellation); + } + } +} diff --git a/back-end/src/Model/UserSettingsEntry.cs b/back-end/src/Model/UserSettingsEntry.cs new file mode 100644 index 0000000..c27af8a --- /dev/null +++ b/back-end/src/Model/UserSettingsEntry.cs @@ -0,0 +1,39 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// This program 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. +// +// This program 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.Text.Json.Serialization; +using System.ComponentModel.DataAnnotations; + +using VNLib.Plugins.Extensions.Data.Abstractions; + +namespace SimpleBookmark.Model +{ + internal sealed class UserSettingsEntry : IUserEntity + { + public DateTime LastModified { get; set; } + + [Timestamp] + [JsonIgnore] + public byte[]? Version { get; set; } + + [Key] + [JsonIgnore] + public string? UserId { get; set; } + + [MaxLength(5000)] + public byte[]? SettingsData { get; set; } + } +} diff --git a/back-end/src/SimpleBookmark.csproj b/back-end/src/SimpleBookmark.csproj new file mode 100644 index 0000000..c1628e6 --- /dev/null +++ b/back-end/src/SimpleBookmark.csproj @@ -0,0 +1,50 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <EnableDynamicLoading>true</EnableDynamicLoading> + <RootNamespace>SimpleBookmark</RootNamespace> + <AssemblyName>SimpleBookmark</AssemblyName> + </PropertyGroup> + + <PropertyGroup> + <Authors>Vaughn Nugent</Authors> + <Company>Vaughn Nugent</Company> + <Product>simple-bookmark</Product> + <Description>A VNLib.Plugins.Essentials plugin that adds Simple Bookmark back-end functionality</Description> + <Copyright>Copyright © 2024 Vaughn Nugent</Copyright> + <PackageProjectUrl>https://www.vaughnnugent.com/resources/software/modules/simple-bookmark</PackageProjectUrl> + <RepositoryUrl>https://github.com/VnUgE/simple-bookmark/tree/master/back-end</RepositoryUrl> + <PackageReadmeFile>README.md</PackageReadmeFile> + <PackageLicenseFile>LICENSE.txt</PackageLicenseFile> + </PropertyGroup> + + <ItemGroup> + <None Include="..\README.md"> + <Pack>True</Pack> + <PackagePath>\</PackagePath> + </None> + <None Include="..\..\LICENSE.txt"> + <Pack>True</Pack> + <PackagePath>\</PackagePath> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="MemoryPack" Version="1.10.0" /> + <PackageReference Include="VNLib.Plugins.Extensions.Data" Version="0.1.0-ci0047" /> + <PackageReference Include="VNLib.Plugins.Extensions.Loading" Version="0.1.0-ci0047" /> + <PackageReference Include="VNLib.Plugins.Extensions.Loading.Sql" Version="0.1.0-ci0047" /> + <PackageReference Include="VNLib.Plugins.Extensions.Validation" Version="0.1.0-ci0047" /> + <PackageReference Include="VNLib.Plugins.Extensions.VNCache" Version="0.1.0-ci0051" /> + </ItemGroup> + + <ItemGroup> + <None Update="SimpleBookmark.json"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + </ItemGroup> + +</Project> diff --git a/back-end/src/SimpleBookmark.json b/back-end/src/SimpleBookmark.json new file mode 100644 index 0000000..56ee217 --- /dev/null +++ b/back-end/src/SimpleBookmark.json @@ -0,0 +1,16 @@ +{ + + //Comments are allowed + "debug": false, + + "bm_endpoint": { + + "path": "/bookmarks", //Path for the bookmarks endpoint + + "config": { + "max_limit": 100, //Max results per page + "default_limit": 20, //Default results per page + "user_quota": 5000 //Max bookmarks per user + } + } +}
\ No newline at end of file diff --git a/back-end/src/SimpleBookmarkEntry.cs b/back-end/src/SimpleBookmarkEntry.cs new file mode 100644 index 0000000..d44aacb --- /dev/null +++ b/back-end/src/SimpleBookmarkEntry.cs @@ -0,0 +1,66 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// This program 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. +// +// This program 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/>. + +/* + * This class/file is the entrypoint for all VNLib.Plugins.Essentials + * projects. It is dynamically loaded by the VNLib.Plugins.Runtime in a + * webserver environment. Some helper libraries are provided to make + * development easier such as VNLib.Plugins.Extensions.Loading and + * VNLib.Plugins.Extensions.Loading.Sql. + */ + +using System; + +using VNLib.Plugins; +using VNLib.Utils.Logging; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Sql; +using VNLib.Plugins.Extensions.Loading.Routing; + +using SimpleBookmark.Model; +using SimpleBookmark.Endpoints; + +namespace SimpleBookmark +{ + + public sealed class SimpleBookmarkEntry : PluginBase + { + ///<inheritdoc/> + public override string PluginName { get; } = "SimpleBookmark"; + + ///<inheritdoc/> + protected override void OnLoad() + { + //route the bm endpoint + this.Route<BookmarkEndpoint>(); + + //Ensure database is created after a delay + this.ObserveWork(() => this.EnsureDbCreatedAsync<SimpleBookmarkContext>(this), 1000); + + Log.Information("Plugin loaded"); + } + + ///<inheritdoc/> + protected override void OnUnLoad() + { + Log.Information("Plugin unloaded"); + } + + protected override void ProcessHostCommand(string cmd) + { + throw new NotImplementedException(); + } + } +} |