aboutsummaryrefslogtreecommitdiff
path: root/back-end
diff options
context:
space:
mode:
Diffstat (limited to 'back-end')
-rw-r--r--back-end/README.md16
-rw-r--r--back-end/Taskfile.yaml48
-rw-r--r--back-end/src/Cache/UserSettings.cs35
-rw-r--r--back-end/src/Cache/UserSettingsStore.cs62
-rw-r--r--back-end/src/Endpoints/BookmarkEndpoint.cs373
-rw-r--r--back-end/src/Model/BookmarkEntry.cs93
-rw-r--r--back-end/src/Model/BookmarkStore.cs140
-rw-r--r--back-end/src/Model/BookmarkStoreConfig.cs42
-rw-r--r--back-end/src/Model/SimpleBookmarkContext.cs78
-rw-r--r--back-end/src/Model/UserSettingsDbStore.cs77
-rw-r--r--back-end/src/Model/UserSettingsEntry.cs39
-rw-r--r--back-end/src/SimpleBookmark.csproj50
-rw-r--r--back-end/src/SimpleBookmark.json16
-rw-r--r--back-end/src/SimpleBookmarkEntry.cs66
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();
+ }
+ }
+}