diff options
Diffstat (limited to 'back-end/src')
-rw-r--r-- | back-end/src/Endpoints/BmAccountEndpoint.cs | 492 | ||||
-rw-r--r-- | back-end/src/Endpoints/BookmarkEndpoint.cs | 50 | ||||
-rw-r--r-- | back-end/src/ImportExportUtil.cs | 10 | ||||
-rw-r--r-- | back-end/src/Model/BookmarkEntry.cs | 44 | ||||
-rw-r--r-- | back-end/src/Model/BookmarkStore.cs | 88 | ||||
-rw-r--r-- | back-end/src/Model/SimpleBookmarkContext.cs | 7 | ||||
-rw-r--r-- | back-end/src/Model/UserSettingsDbStore.cs | 6 | ||||
-rw-r--r-- | back-end/src/RoleHelpers.cs | 42 | ||||
-rw-r--r-- | back-end/src/SimpleBookmark.csproj | 10 | ||||
-rw-r--r-- | back-end/src/SimpleBookmark.json | 16 | ||||
-rw-r--r-- | back-end/src/SimpleBookmarkEntry.cs | 12 |
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); } } } |