aboutsummaryrefslogtreecommitdiff
path: root/back-end
diff options
context:
space:
mode:
Diffstat (limited to 'back-end')
-rw-r--r--back-end/src/Endpoints/BmAccountEndpoint.cs492
-rw-r--r--back-end/src/Endpoints/BookmarkEndpoint.cs50
-rw-r--r--back-end/src/ImportExportUtil.cs10
-rw-r--r--back-end/src/Model/BookmarkEntry.cs44
-rw-r--r--back-end/src/Model/BookmarkStore.cs88
-rw-r--r--back-end/src/Model/SimpleBookmarkContext.cs7
-rw-r--r--back-end/src/Model/UserSettingsDbStore.cs6
-rw-r--r--back-end/src/RoleHelpers.cs42
-rw-r--r--back-end/src/SimpleBookmark.csproj10
-rw-r--r--back-end/src/SimpleBookmark.json16
-rw-r--r--back-end/src/SimpleBookmarkEntry.cs12
11 files changed, 707 insertions, 70 deletions
diff --git a/back-end/src/Endpoints/BmAccountEndpoint.cs b/back-end/src/Endpoints/BmAccountEndpoint.cs
new file mode 100644
index 0000000..9b57d39
--- /dev/null
+++ b/back-end/src/Endpoints/BmAccountEndpoint.cs
@@ -0,0 +1,492 @@
+// 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.Threading;
+using System.Threading.Tasks;
+using System.Text.Json.Serialization;
+
+using FluentValidation;
+
+using VNLib.Utils.Memory;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Extensions;
+using VNLib.Hashing;
+using VNLib.Hashing.IdentityUtility;
+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.Essentials.Users;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Validation;
+using VNLib.Plugins.Extensions.Loading.Users;
+using VNLib.Plugins.Extensions.Loading.Events;
+
+using SimpleBookmark.Model;
+
+namespace SimpleBookmark.Endpoints
+{
+ [ConfigurationName("registration")]
+ internal sealed class BmAccountEndpoint : UnprotectedWebEndpoint
+ {
+
+ private static readonly IValidator<NewUserRequest> NewRequestVal = NewUserRequest.GetValidator();
+ private static readonly IValidator<RegSubmitRequest> RegSubmitVal = RegSubmitRequest.GetValidator();
+ private static readonly IValidator<RegSubmitRequest> AdminRegVal = RegSubmitRequest.GetAdminValidator();
+
+ private readonly IUserManager Users;
+ private readonly TimeSpan Expiration;
+ private readonly JwtAuthManager AuthMan;
+ //private readonly BookmarkStore Bookmarks;
+ private readonly bool SetupMode;
+ private readonly bool Enabled;
+
+ public BmAccountEndpoint(PluginBase plugin, IConfigScope config)
+ {
+ string path = config.GetRequiredProperty("path", p => p.GetString()!);
+ InitPathAndLog(path, plugin.Log);
+
+ //get setup mode and enabled startup arguments
+ SetupMode = plugin.HostArgs.HasArgument("--setup");
+ Enabled = !plugin.HostArgs.HasArgument("--disable-registation");
+
+ Expiration = config.GetRequiredProperty("token_lifetime_mins", p => p.GetTimeSpan(TimeParseType.Minutes));
+
+ Users = plugin.GetOrCreateSingleton<UserManager>();
+ //Bookmarks = plugin.GetOrCreateSingleton<BookmarkStore>();
+
+ /*
+ * JWT manager allows regenerating the signing key on a set interval.
+ *
+ * This means that if keys are generated on the edge of an interval,
+ * it will expire at the next interval which could be much shorter
+ * than the set interval. This is a security feature to prevent
+ * long term exposure of a signing key.
+ *
+ */
+ AuthMan = new JwtAuthManager();
+
+ if(config.TryGetProperty("key_regen_interval_mins", p => p.GetTimeSpan(TimeParseType.Minutes), out TimeSpan regen))
+ {
+ plugin.ScheduleInterval(AuthMan, regen, false);
+ }
+ }
+
+ //Essentially a whoami endpoint for current user
+ protected override VfReturnType Get(HttpEntity entity)
+ {
+ WebMessage msg = new()
+ {
+ Success = true
+ };
+
+ //Only authorized users can check their status
+ if (Enabled && entity.IsClientAuthorized(AuthorzationCheckLevel.Critical))
+ {
+ //Pass user status when logged in
+ msg.Result = new StatusResponse
+ {
+ SetupMode = SetupMode,
+ Enabled = Enabled,
+ CanInvite = entity.Session.CanAddUser(),
+ ExpirationTime = (int)Expiration.TotalSeconds
+ };
+ }
+ else
+ {
+ msg.Result = new StatusResponse
+ {
+ SetupMode = SetupMode,
+ Enabled = Enabled
+ };
+ }
+
+ return VirtualOk(entity, msg);
+ }
+
+ /*
+ * PUT will generate a new user request if the current has an admin
+ * role. This will return a jwt token that can be used to register a new
+ * user account. The token will expire after a set time.
+ */
+
+ protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ if (webm.Assert(Enabled, "User registation was disabled via commandline"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
+ }
+
+ //Only authorized users can generate new requests
+ if(!entity.IsClientAuthorized(AuthorzationCheckLevel.Critical))
+ {
+ webm.Result = "You do not have permissions to create new users";
+ return VirtualClose(entity, webm, HttpStatusCode.Unauthorized);
+ }
+
+ //Make sure user is an admin
+ if (webm.Assert(entity.Session.CanAddUser(), "You do not have permissions to create new users"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
+ }
+
+ //Try to get the new user request
+ NewUserRequest? request = entity.GetJsonFromFile<NewUserRequest>();
+ if (webm.Assert(request != null, "No request body provided"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if(!NewRequestVal.Validate(request, webm))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
+ }
+
+ //Make sure the user does not already exist
+ using (IUser? user = await Users.GetUserFromUsernameAsync(request.Username!, entity.EventCancellation))
+ {
+ if (webm.Assert(user == null, "User already exists"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.Conflict);
+ }
+ }
+
+ //Start with min user privilages
+ ulong privileges = RoleHelpers.MinUserRole;
+
+ //Optionally allow the user to add new userse
+ if(request.CanAddUsers)
+ {
+ privileges = RoleHelpers.WithAddUserRole(privileges);
+ }
+
+ //Init new request
+ using JsonWebToken jwt = new();
+
+ //issue new payload for registration
+ jwt.WritePayload(new JwtPayload
+ {
+ Expiration = entity.RequestedTimeUtc.Add(Expiration).ToUnixTimeSeconds(),
+ Subject = request.Username!,
+ PrivLevel = privileges,
+ Nonce = RandomHash.GetRandomHex(16)
+ });
+
+ AuthMan.SignJwt(jwt);
+ webm.Result = jwt.Compile();
+ webm.Success = true;
+
+ return VirtualClose(entity, webm, HttpStatusCode.OK);
+ }
+
+ protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ if (webm.Assert(Enabled, "User registation was disabled via commandline."))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
+ }
+
+ using RegSubmitRequest? req = await entity.GetJsonFromFileAsync<RegSubmitRequest>();
+
+ if (webm.Assert(req != null, "No request body provided."))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ //Users can specify an admin username when setup mode is enabled
+ if (!string.IsNullOrWhiteSpace(req.AdminUsername))
+ {
+ if(webm.Assert(SetupMode, "Admin registation is not enabled."))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
+ }
+
+ //Validate against admin reg
+ if(!AdminRegVal.Validate(req, webm))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
+ }
+
+ try
+ {
+ //Default to min user privilages + add user privilages, technically the same as admin here
+ ulong adminPriv = RoleHelpers.WithAddUserRole(RoleHelpers.MinUserRole);
+
+ await CreateUserAsync(req.AdminUsername, adminPriv, req.Password, entity.EventCancellation);
+
+ webm.Result = "Successfully created your new admin account.";
+ webm.Success = true;
+
+ return VirtualClose(entity, webm, HttpStatusCode.Created);
+ }
+ catch (UserExistsException)
+ {
+ webm.Result = "User account already exists";
+ return VirtualClose(entity, webm, HttpStatusCode.Conflict);
+ }
+ }
+
+ //Normal link registration
+ if(!RegSubmitVal.Validate(req, webm))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
+ }
+
+ try
+ {
+ //Try to recover the initial jwt from the request
+ using JsonWebToken jwt = JsonWebToken.Parse(req.Token!);
+
+ if (webm.Assert(AuthMan.VerifyJwt(jwt), "Registation failed, your link is invalid."))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ JwtPayload p = jwt.GetPayload<JwtPayload>()!;
+
+ //Make sure the token is not expired
+ if (webm.Assert(p.Expiration > entity.RequestedTimeUtc.ToUnixTimeSeconds(), "Registation failed, your link has expired"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ //Check that the user is not already registered
+ using (IUser? user = await Users.GetUserFromUsernameAsync(p.Subject, entity.EventCancellation))
+ {
+ if (webm.Assert(user == null, "Your account already exists"))
+ {
+ /*
+ * It should be fine to tell the user that the account already exists
+ * because login tokens are "secret" and the user would have to know
+ * the token to use it.
+ */
+
+ return VirtualClose(entity, webm, HttpStatusCode.Conflict);
+ }
+ }
+
+ //Create the new user
+ await CreateUserAsync(p.Subject, p.PrivLevel, req.Password, entity.EventCancellation);
+
+ webm.Result = "Successfully created you new account!";
+ webm.Success = true;
+
+ return VirtualClose(entity, webm, HttpStatusCode.Created);
+ }
+ catch (FormatException)
+ {
+ webm.Result = "Registation failed, your link is invalid.";
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+ }
+
+ private async Task CreateUserAsync(string userName, ulong privLevel, string? password, CancellationToken cancellation)
+ {
+ //Create the new user
+ UserCreationRequest newUser = new()
+ {
+ EmailAddress = userName,
+ InitialStatus = UserStatus.Active,
+ Privileges = privLevel,
+ Password = PrivateString.ToPrivateString(password, false),
+ };
+
+ using IUser user = await Users.CreateUserAsync(newUser, null, cancellation);
+
+ //Assign a local account status and email address
+ user.SetAccountOrigin(AccountUtil.LOCAL_ACCOUNT_ORIGIN);
+ user.EmailAddress = userName;
+
+ await user.ReleaseAsync(cancellation);
+ }
+
+
+ /*
+ * TODO
+ * USERS DELETE OWN ACCOUNTS HERE
+ *
+ * Users may delete their own accounts if they are logged in.
+ * This function should delete all bookmarks, and their own account
+ * from the table. This requires password elevation aswell.
+ */
+ protected override ValueTask<VfReturnType> DeleteAsync(HttpEntity entity)
+ {
+ return base.DeleteAsync(entity);
+ }
+
+ private sealed class JwtAuthManager() : IIntervalScheduleable
+ {
+ /*
+ * Random signing keys are rotated on the configured expiration
+ * interval.
+ */
+
+ private byte[] secretKey = RandomHash.GetRandomBytes(64);
+
+ Task IIntervalScheduleable.OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken)
+ {
+ secretKey = RandomHash.GetRandomBytes(64);
+ return Task.CompletedTask;
+ }
+
+ public void SignJwt(JsonWebToken jwt)
+ {
+ if (ManagedHash.IsAlgSupported(HashAlg.BlAKE2B))
+ {
+ jwt.Sign(secretKey, HashAlg.BlAKE2B);
+ }
+ else if (ManagedHash.IsAlgSupported(HashAlg.SHA3_256))
+ {
+ jwt.Sign(secretKey, HashAlg.SHA3_256);
+ }
+ else
+ {
+ //fallback to sha256
+ jwt.Sign(secretKey, HashAlg.SHA256);
+ }
+ }
+
+ public bool VerifyJwt(JsonWebToken jwt)
+ {
+ if (ManagedHash.IsAlgSupported(HashAlg.BlAKE2B))
+ {
+ return jwt.Verify(secretKey, HashAlg.BlAKE2B);
+ }
+ else if (ManagedHash.IsAlgSupported(HashAlg.SHA3_256))
+ {
+ return jwt.Verify(secretKey, HashAlg.SHA3_256);
+ }
+ else
+ {
+ //fallback to sha256
+ return jwt.Verify(secretKey, HashAlg.SHA256);
+ }
+ }
+ }
+
+
+ private sealed class JwtPayload
+ {
+ [JsonPropertyName("sub")]
+ public string Subject { get; set; } = string.Empty;
+
+ [JsonPropertyName("level")]
+ public ulong PrivLevel { get; set; }
+
+ [JsonPropertyName("exp")]
+ public long Expiration { get; set; }
+
+ [JsonPropertyName("n")]
+ public string Nonce { get; set; } = string.Empty;
+ }
+
+ private sealed class NewUserRequest
+ {
+ [JsonPropertyName("username")]
+ public string Username { get; set; } = string.Empty;
+
+ [JsonPropertyName("can_add_users")]
+ public bool CanAddUsers { get; set; }
+
+ public static IValidator<NewUserRequest> GetValidator()
+ {
+ InlineValidator<NewUserRequest> val = new();
+
+ val.RuleFor(p => p.Username)
+ .NotNull()
+ .NotEmpty()
+ .EmailAddress()
+ .Length(1, 200);
+
+ return val;
+ }
+ }
+
+ private sealed class StatusResponse
+ {
+ [JsonPropertyName("setup_mode")]
+ public bool SetupMode { get; set; }
+
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; }
+
+ [JsonPropertyName("can_invite")]
+ public bool? CanInvite { get; set; }
+
+ [JsonPropertyName("link_expiration")]
+ public int? ExpirationTime { get; set; }
+ }
+
+ private sealed class RegSubmitRequest() : PrivateStringManager(1)
+ {
+ [JsonPropertyName("token")]
+ public string? Token { get; set; }
+
+ [JsonPropertyName("admin_username")]
+ public string? AdminUsername { get; set; }
+
+ [JsonPropertyName("password")]
+ public string? Password
+ {
+ get => base[0];
+ set => base[0] = value;
+ }
+
+ public static IValidator<RegSubmitRequest> GetValidator()
+ {
+ InlineValidator<RegSubmitRequest> val = new();
+
+ val.RuleFor(p => p.Token)
+ .NotNull()
+ .NotEmpty()
+ .Length(1, 500);
+
+ val.RuleFor(p => p.Password)
+ .NotEmpty()
+ .Length(min: 8, max: 100)
+ .Password()
+ .WithMessage(errorMessage: "Password does not meet minium requirements");
+
+ return val;
+ }
+
+ public static IValidator<RegSubmitRequest> GetAdminValidator()
+ {
+ InlineValidator<RegSubmitRequest> val = new();
+
+ val.RuleFor(p => p.Password)
+ .NotEmpty()
+ .Length(min: 8, max: 100)
+ .Password()
+ .WithMessage(errorMessage: "Password does not meet minium requirements");
+
+ val.RuleFor(p => p.AdminUsername)
+ .NotNull()
+ .NotEmpty()
+ .EmailAddress()
+ .Length(1, 200);
+
+ return val;
+ }
+ }
+ }
+}
diff --git a/back-end/src/Endpoints/BookmarkEndpoint.cs b/back-end/src/Endpoints/BookmarkEndpoint.cs
index e6e388d..001a41b 100644
--- a/back-end/src/Endpoints/BookmarkEndpoint.cs
+++ b/back-end/src/Endpoints/BookmarkEndpoint.cs
@@ -31,6 +31,7 @@ using Microsoft.EntityFrameworkCore;
using VNLib.Utils;
using VNLib.Utils.IO;
using VNLib.Utils.Memory;
+using VNLib.Utils.Extensions;
using VNLib.Net.Http;
using VNLib.Plugins;
using VNLib.Plugins.Essentials;
@@ -38,7 +39,6 @@ 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;
@@ -60,9 +60,8 @@ namespace SimpleBookmark.Endpoints
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);
+ //Init bookmark store
+ Bookmarks = plugin.GetOrCreateSingleton<BookmarkStore>();
//Load config
BmConfig = config.GetRequiredProperty("config", p => p.Deserialize<BookmarkStoreConfig>()!);
@@ -342,7 +341,7 @@ namespace SimpleBookmark.Endpoints
if (failOnInvalid)
{
//Get any invalid entires and create a validation result
- BookmarkError[] invalidEntires = sanitized.Select(b =>
+ BookmarkError[] invalidEntires = sanitized.Select(static b =>
{
ValidationResult result = BmValidator.Validate(b);
if(result.IsValid)
@@ -378,16 +377,24 @@ namespace SimpleBookmark.Endpoints
//Remove any invalid entires
sanitized = sanitized.Where(static b => BmValidator.Validate(b).IsValid);
}
+ try
+ {
+ //Try to update the records
+ ERRNO result = await Bookmarks.AddBulkAsync(sanitized, entity.Session.UserID, entity.RequestedTimeUtc, entity.EventCancellation);
- //Try to update the records
- ERRNO result = await Bookmarks.AddBulkAsync(sanitized, entity.Session.UserID, entity.RequestedTimeUtc, entity.EventCancellation);
-
- webm.Result = $"Successfully added {result} of {batch.Length} bookmarks";
- webm.Success = true;
+ webm.Result = $"Successfully added {result} of {batch.Length} bookmarks";
+ webm.Success = true;
- return VirtualClose(entity, webm, HttpStatusCode.OK);
+ return VirtualClose(entity, webm, HttpStatusCode.OK);
+ }
+ catch (DbUpdateException dbe) when(dbe.InnerException is not null)
+ {
+ //Set entire batch as an error
+ webm.Result = GetResultFromEntires(batch, dbe.InnerException.Message);
+ return VirtualOk(entity, webm);
+ }
}
-
+
///<inheritdoc/>
protected override async ValueTask<VfReturnType> DeleteAsync(HttpEntity entity)
{
@@ -418,10 +425,29 @@ namespace SimpleBookmark.Endpoints
return VirtualClose(entity, webm, HttpStatusCode.OK);
}
+ private static BatchUploadResult GetResultFromEntires(IEnumerable<BookmarkEntry> errors, string message)
+ {
+ BookmarkError[] invalidEntires = errors.Select(e => new BookmarkError
+ {
+ Errors = new object[] { new ValidationFailure(string.Empty, message) },
+ Subject = e
+ }).ToArray();
+
+ return new BatchUploadResult()
+ {
+ Errors = invalidEntires,
+ Message = message
+ };
+ }
+
+
sealed class BatchUploadResult
{
[JsonPropertyName("invalid")]
public BookmarkError[]? Errors { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
}
sealed class BookmarkError
diff --git a/back-end/src/ImportExportUtil.cs b/back-end/src/ImportExportUtil.cs
index 6fa554c..aff7109 100644
--- a/back-end/src/ImportExportUtil.cs
+++ b/back-end/src/ImportExportUtil.cs
@@ -16,17 +16,16 @@
using System;
using System.IO;
using System.Text.Json;
-using SimpleBookmark.Model;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using VNLib.Utils.IO;
-
+using SimpleBookmark.Model;
namespace SimpleBookmark
{
- internal static class ImportExportUtil
+ internal static partial class ImportExportUtil
{
/// <summary>
/// Exports a colletion of bookmarks to a netscape bookmark file
@@ -78,7 +77,7 @@ namespace SimpleBookmark
}
//Remove illegal characters from a string, ", \, and control characters
- private static readonly Regex _illegalChars = new("[\"\\p{Cc}]", RegexOptions.Compiled);
+ private static readonly Regex _illegalChars = GetIllegalCharsReg();
private static string? Escape(string? input)
{
@@ -140,5 +139,8 @@ namespace SimpleBookmark
writer.WriteEndArray();
}
+
+ [GeneratedRegex("[\"\\p{Cc}]", RegexOptions.Compiled)]
+ private static partial Regex GetIllegalCharsReg();
}
}
diff --git a/back-end/src/Model/BookmarkEntry.cs b/back-end/src/Model/BookmarkEntry.cs
index 0ce7644..fc981ec 100644
--- a/back-end/src/Model/BookmarkEntry.cs
+++ b/back-end/src/Model/BookmarkEntry.cs
@@ -32,6 +32,7 @@ namespace SimpleBookmark.Model
internal sealed partial class BookmarkEntry : DbModelBase, IUserEntity, IJsonOnDeserialized
{
[Key]
+ [MaxLength(64)]
public override string Id { get; set; }
public override DateTime Created { get; set; }
@@ -39,12 +40,13 @@ namespace SimpleBookmark.Model
public override DateTime LastModified { get; set; }
[JsonIgnore]
+ [MaxLength(64)]
public string? UserId { get; set; }
- [MaxLength(100)]
+ [MaxLength(200)]
public string? Name { get; set; }
- [MaxLength(200)]
+ [MaxLength(300)]
public string? Url { get; set; }
[MaxLength(500)]
@@ -53,7 +55,7 @@ namespace SimpleBookmark.Model
//Json flavor
[NotMapped]
[JsonPropertyName("Tags")]
- public string[]? JsonTags
+ public string?[]? JsonTags
{
get => Tags?.Split(',');
set => Tags = value is null ? null : string.Join(',', value);
@@ -61,7 +63,7 @@ namespace SimpleBookmark.Model
//Database flavor as string
[JsonIgnore]
- [MaxLength(100)]
+ [MaxLength(100)]
public string? Tags { get; set; }
public static IValidator<BookmarkEntry> GetValidator()
@@ -71,21 +73,29 @@ namespace SimpleBookmark.Model
validator.RuleFor(p => p.Name)
.NotEmpty()
.Matches(@"^[a-zA-Z0-9_\-\|\., ]+$", RegexOptions.Compiled)
- .MaximumLength(100);
+ .MaximumLength(200);
validator.RuleFor(p => p.Url)
.NotEmpty()
.Matches(@"^https?://", RegexOptions.Compiled)
- .MaximumLength(200);
+ .MaximumLength(300);
+ //Description should be valid utf-8 and not exceed 500 characters
validator.RuleFor(p => p.Description)
+ .Matches(@"^[\u0000-\u007F]+$", RegexOptions.Compiled).When(p => !string.IsNullOrEmpty(p.Description))
+ .WithMessage("Description contains illegal unicode characters")
.MaximumLength(500);
- //Tags must be non-empty and alphanumeric only, no spaces
+ //Tags must be non-empty and alphanumeric only, no spaces, only if tags are not null
validator.RuleForEach(p => p.JsonTags)
- .NotNull()
- .NotEmpty()
- .Matches(@"^[a-zA-Z0-9]+$", RegexOptions.Compiled);
+ .Matches(@"^[a-zA-Z0-9\-]+$", RegexOptions.Compiled).When(v => v.JsonTags is not null && v.JsonTags.Length > 0, ApplyConditionTo.CurrentValidator)
+ .WithMessage("Tags for this bookmark contain invalid characters -> {PropertyValue}")
+ .Length(1, 64).When(v => v.JsonTags is not null && v.JsonTags.Length > 0, ApplyConditionTo.CurrentValidator)
+ .WithMessage("One or more tags for this bookmark are too long");
+
+ validator.RuleFor(p => p.Tags)
+ .MaximumLength(100)
+ .WithMessage("You have too many tags or tag names are too long");
return validator;
}
@@ -96,6 +106,20 @@ namespace SimpleBookmark.Model
Name = Name?.Trim();
Url = Url?.Trim();
Description = Description?.Trim();
+
+ //Trim tags array
+ if(JsonTags != null)
+ {
+ for (int i = 0; i < JsonTags.Length; i++)
+ {
+ JsonTags[i] = JsonTags[i].Trim();
+ }
+ }
+
+ if(string.IsNullOrWhiteSpace(Tags))
+ {
+ Tags = null;
+ }
}
}
}
diff --git a/back-end/src/Model/BookmarkStore.cs b/back-end/src/Model/BookmarkStore.cs
index 8578976..ec020e8 100644
--- a/back-end/src/Model/BookmarkStore.cs
+++ b/back-end/src/Model/BookmarkStore.cs
@@ -22,15 +22,18 @@ using System.Threading.Tasks;
using System.Collections.Generic;
using VNLib.Utils;
+using VNLib.Plugins;
using VNLib.Plugins.Extensions.Data;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Data.Abstractions;
-
+using VNLib.Plugins.Extensions.Loading.Sql;
namespace SimpleBookmark.Model
{
- internal sealed class BookmarkStore(IAsyncLazy<DbContextOptions> dbOptions) : DbStore<BookmarkEntry>
+ internal sealed class BookmarkStore(PluginBase plugin) : DbStore<BookmarkEntry>
{
+ private readonly IAsyncLazy<DbContextOptions> dbOptions = plugin.GetContextOptionsAsync();
+
///<inheritdoc/>
public override IDbQueryLookup<BookmarkEntry> QueryTable { get; } = new BookmarkQueryLookup();
@@ -52,6 +55,8 @@ namespace SimpleBookmark.Model
public async Task<BookmarkEntry[]> SearchBookmarksAsync(string userId, string? query, string[] tags, int limit, int page, CancellationToken cancellation)
{
+ BookmarkEntry[] results;
+
ArgumentNullException.ThrowIfNull(userId);
ArgumentNullException.ThrowIfNull(tags);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(limit, 0);
@@ -59,31 +64,56 @@ namespace SimpleBookmark.Model
//Init new db connection
await using SimpleBookmarkContext context = new(dbOptions.Value);
- await context.OpenTransactionAsync(cancellation);
- //Start with userid
+ //Build the query starting with the user's bookmarks
IQueryable<BookmarkEntry> q = context.Bookmarks.Where(b => b.UserId == userId);
+ //reduce result set by search query first
+ if (!string.IsNullOrWhiteSpace(query))
+ {
+ q = WithSearch(q, query);
+ }
+
+ q = q.OrderByDescending(static b => b.Created);
+
+ /*
+ * For some databases server-side tag filtering is not supported.
+ * Client side evaluation must be used to finally filter the results.
+ *
+ * I am attempting to reduce the result set as much as possible on server-side
+ * evaluation before pulling the results into memory. So search, ordering and
+ * first tag filtering is done on server-side. The final tag filtering is done
+ * for multiple tags on client-side along with pagination. For bookmarks, I expect
+ * the result set to at worst double digits for most users, so this should be fine.
+ *
+ */
+
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)));
+
+ //filter out bookmarks that do not have any tags and reduce by the first tag
+ q = q.Where(static b => b.Tags != null && b.Tags.Length > 0)
+ .Where(b => EF.Functions.Like(b.Tags, $"%{tags[0]}%"));
}
- if (!string.IsNullOrWhiteSpace(query))
+ if(tags.Length > 1)
{
- //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}%"));
+ //Finally pull all results into memory
+ BookmarkEntry[] bookmarkEntries = await q.ToArrayAsync(cancellation);
+
+ //filter out bookmarks that do not have all requested tags, then skip and take the requested page
+ results = bookmarkEntries.Where(b => tags.All(p => b.JsonTags!.Contains(p)))
+ .Skip(page * limit)
+ .Take(limit)
+ .ToArray();
}
-
- //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);
+ else
+ {
+ //execute server-side query
+ results = await q.Skip(page * limit)
+ .Take(limit)
+ .ToArrayAsync(cancellation);
+ }
//Close db and commit transaction
await context.SaveAndCloseAsync(true, cancellation);
@@ -91,14 +121,19 @@ namespace SimpleBookmark.Model
return results;
}
+ private static IQueryable<BookmarkEntry> WithSearch(IQueryable<BookmarkEntry> q, string query)
+ {
+ //if query is set, only return bookmarks that match the query
+ return q.Where(b => EF.Functions.Like(b.Name, $"%{query}%") || EF.Functions.Like(b.Description, $"%{query}%"));
+ }
+
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)
@@ -116,11 +151,19 @@ namespace SimpleBookmark.Model
.ToArray();
}
+ public async Task<ERRNO> DeleteAllForUserAsync(string userId, CancellationToken cancellation)
+ {
+ await using SimpleBookmarkContext context = new(dbOptions.Value);
+
+ context.Bookmarks.RemoveRange(context.Bookmarks.Where(b => b.UserId == userId));
+
+ return await context.SaveAndCloseAsync(true, cancellation);
+ }
+
public async Task<ERRNO> AddBulkAsync(IEnumerable<BookmarkEntry> bookmarks, string userId, DateTimeOffset now, CancellationToken cancellation)
{
//Init new db connection
await using SimpleBookmarkContext context = new(dbOptions.Value);
- await context.OpenTransactionAsync(cancellation);
//Setup clean bookmark instances
bookmarks = bookmarks.Select(b => new BookmarkEntry
@@ -162,8 +205,7 @@ namespace SimpleBookmark.Model
public IQueryable<BookmarkEntry> GetSingleQueryBuilder(IDbContextHandle context, params string[] constraints)
{
string bookmarkId = constraints[0];
- string userId = constraints[1];
-
+ string userId = constraints[1];
return from b in context.Set<BookmarkEntry>()
where b.UserId == userId && b.Id == bookmarkId
diff --git a/back-end/src/Model/SimpleBookmarkContext.cs b/back-end/src/Model/SimpleBookmarkContext.cs
index 2470695..25343d9 100644
--- a/back-end/src/Model/SimpleBookmarkContext.cs
+++ b/back-end/src/Model/SimpleBookmarkContext.cs
@@ -22,12 +22,12 @@ using VNLib.Plugins.Extensions.Loading.Sql;
namespace SimpleBookmark.Model
{
- internal sealed class SimpleBookmarkContext : TransactionalDbContext, IDbTableDefinition
+ internal sealed class SimpleBookmarkContext : DBContextBase, IDbTableDefinition
{
public DbSet<BookmarkEntry> Bookmarks { get; set; }
- public DbSet<UserSettingsEntry> BmSettings { get; set; }
+ public DbSet<UserSettingsEntry> SbSettings { get; set; }
public SimpleBookmarkContext(DbContextOptions options) : base(options)
{ }
@@ -56,7 +56,6 @@ namespace SimpleBookmark.Model
.WithColumn(p => p.Name)
.AllowNull(true)
- .MaxLength(100)
.Next()
.WithColumn(p => p.Version)
@@ -70,12 +69,10 @@ namespace SimpleBookmark.Model
.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
index aa44fa2..d392262 100644
--- a/back-end/src/Model/UserSettingsDbStore.cs
+++ b/back-end/src/Model/UserSettingsDbStore.cs
@@ -33,9 +33,8 @@ namespace SimpleBookmark.Model
//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);
+ UserSettingsEntry? settings = await context.SbSettings.FirstOrDefaultAsync(p => p.UserId == userId, cancellation);
//Close db and commit transaction
await context.SaveAndCloseAsync(true, cancellation);
@@ -50,10 +49,9 @@ namespace SimpleBookmark.Model
//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);
+ UserSettingsEntry? existing = await context.SbSettings.FirstOrDefaultAsync(p => p.UserId == userId, cancellation);
if (existing is null)
{
diff --git a/back-end/src/RoleHelpers.cs b/back-end/src/RoleHelpers.cs
new file mode 100644
index 0000000..a49d72a
--- /dev/null
+++ b/back-end/src/RoleHelpers.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 VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Plugins.Essentials.Sessions;
+
+namespace SimpleBookmark
+{
+ internal static class RoleHelpers
+ {
+ public const ulong CanAddUserRoleOption = 1 << AccountUtil.OPTIONS_MSK_OFFSET;
+
+ /// <summary>
+ /// A minium user role with read/write/delete access to their own bookmarks.
+ /// </summary>
+ public const ulong MinUserRole = AccountUtil.MINIMUM_LEVEL | AccountUtil.ALLFILE_MSK;
+
+ public static bool CanAddUser(this IUser user) => (user.Privileges & CanAddUserRoleOption) != 0;
+
+ public static bool CanAddUser(this ref readonly SessionInfo session) => (session.Privilages & CanAddUserRoleOption) != 0;
+
+ /// <summary>
+ /// Adds the add-user role to the given privileges for a user.
+ /// </summary>
+ /// <param name="privs"></param>
+ /// <returns>The modified privilege level</returns>
+ public static ulong WithAddUserRole(ulong privs) => privs | CanAddUserRoleOption;
+ }
+}
diff --git a/back-end/src/SimpleBookmark.csproj b/back-end/src/SimpleBookmark.csproj
index 581c3af..03d3b03 100644
--- a/back-end/src/SimpleBookmark.csproj
+++ b/back-end/src/SimpleBookmark.csproj
@@ -34,11 +34,11 @@
<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" />
+ <PackageReference Include="VNLib.Plugins.Extensions.Data" Version="0.1.0-ci0049" />
+ <PackageReference Include="VNLib.Plugins.Extensions.Loading" Version="0.1.0-ci0049" />
+ <PackageReference Include="VNLib.Plugins.Extensions.Loading.Sql" Version="0.1.0-ci0049" />
+ <PackageReference Include="VNLib.Plugins.Extensions.Validation" Version="0.1.0-ci0049" />
+ <PackageReference Include="VNLib.Plugins.Extensions.VNCache" Version="0.1.0-ci0052" />
</ItemGroup>
<ItemGroup>
diff --git a/back-end/src/SimpleBookmark.json b/back-end/src/SimpleBookmark.json
index 56ee217..27ebff8 100644
--- a/back-end/src/SimpleBookmark.json
+++ b/back-end/src/SimpleBookmark.json
@@ -1,16 +1,22 @@
{
//Comments are allowed
- "debug": false,
+ "debug": false, //Enables obnoxious debug logging
"bm_endpoint": {
- "path": "/bookmarks", //Path for the bookmarks 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
+ "max_limit": 100, //Max results per page
+ "default_limit": 20, //Default results per page
+ "user_quota": 5000 //Max bookmarks per user
}
+ },
+
+ "registration": {
+ "path": "/register", //Path for the registration endpoint
+ "token_lifetime_mins": 360, //Token lifetime in minutes
+ "key_regen_interval_mins": 3600 //Signing key regeneration interval in minutes
}
} \ No newline at end of file
diff --git a/back-end/src/SimpleBookmarkEntry.cs b/back-end/src/SimpleBookmarkEntry.cs
index 48fcb2a..a1c9590 100644
--- a/back-end/src/SimpleBookmarkEntry.cs
+++ b/back-end/src/SimpleBookmarkEntry.cs
@@ -49,6 +49,7 @@ namespace SimpleBookmark
{
//route the bm endpoint
this.Route<BookmarkEndpoint>();
+ this.Route<BmAccountEndpoint>();
//Ensure database is created after a delay
this.ObserveWork(() => this.EnsureDbCreatedAsync<SimpleBookmarkContext>(this), 1000);
@@ -81,7 +82,7 @@ namespace SimpleBookmark
Documentation: https://www.vaughnnugent.com/resources/software/articles?tags=docs,_simple-bookmark
GitHub: https://github.com/VnUgE/simple-bookmark
-
+ {warning}
Your server is now running at the following locations:{0}
******************************************************************************";
@@ -103,7 +104,14 @@ namespace SimpleBookmark
sb.AppendLine(intf);
}
- Log.Information(template, sb);
+ //See if setup mode is enabled
+ bool setupMode = HostArgs.HasArgument("--setup") && !HostArgs.HasArgument("--disable-registation");
+
+ string warnMessage = setupMode
+ ? "\nWARNING: This server is in setup mode. Account registation is open to all users.\n"
+ : string.Empty;
+
+ Log.Information(template, warnMessage, sb);
}
}
}