diff options
Diffstat (limited to 'back-end/src')
-rw-r--r-- | back-end/src/Endpoints/WidgetEndpoint.cs | 160 | ||||
-rw-r--r-- | back-end/src/Model/Widget/WidgetAuthManager.cs | 152 | ||||
-rw-r--r-- | back-end/src/SimpleBookmark.json | 30 | ||||
-rw-r--r-- | back-end/src/SimpleBookmarkEntry.cs | 1 |
4 files changed, 332 insertions, 11 deletions
diff --git a/back-end/src/Endpoints/WidgetEndpoint.cs b/back-end/src/Endpoints/WidgetEndpoint.cs new file mode 100644 index 0000000..ad2fa39 --- /dev/null +++ b/back-end/src/Endpoints/WidgetEndpoint.cs @@ -0,0 +1,160 @@ +// 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.Tasks; +using System.Text; + +using VNLib.Utils.IO; +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Net.Http; +using VNLib.Plugins; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.Loading.Routing; + +using SimpleBookmark.Model; +using SimpleBookmark.Model.Widget; + +namespace SimpleBookmark.Endpoints +{ + [EndpointPath("{{path}}")] + [EndpointLogName("widget-endpoint")] + [ConfigurationName("widgets")] + internal sealed class WidgetEndpoint(PluginBase plugin, IConfigScope config) : UnprotectedWebEndpoint + { + private readonly BookmarkStore bookmarks = plugin.GetOrCreateSingleton<BookmarkStore>(); + private readonly WidgetAuthManager authManager = plugin.GetOrCreateSingleton<WidgetAuthManager>(); + private readonly bool Enabled = config.GetValueOrDefault("enabled", false); + private readonly string? CorsAclHeaerDomains = config.GetValueOrDefault<string?>("cors-urls", null); + + protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity) + { + if (!Enabled) + { + return VfReturnType.NotFound; + } + + /* if (!await authManager.IsTokenValidAsync(entity)) + { + return VirtualClose(entity, HttpStatusCode.Unauthorized); + }*/ + + //Widgets might be loaded in an iframe, so we need to allow cross-site requests + if (CorsAclHeaerDomains is not null && entity.Server.IsCrossSite()) + { + entity.Server.Headers.Append("Access-Control-Allow-Origin", CorsAclHeaerDomains); + } + + /* + * For now this just returns bookmarks with the "favorite" tag + * an assume the auth manager has set the session's user-id field + */ + + BookmarkEntry[] boomarks = await bookmarks.SearchBookmarksAsync( + entity.Session.UserID, + query: null, + tags: ["favorite"], + limit: 25, + page: 1, + entity.EventCancellation + ); + + //Output memory buffer + VnMemoryStream output = new(32 * 1024, false); + + try + { + string baseUrl = entity.Server.RequestUri.GetLeftPart(UriPartial.Authority); + + CompileGlanceTemplate(baseUrl, output, boomarks); + + //Assign glance template + entity.Server.Headers["Widget-Title"] = "Simple-Bookmark Widget"; + entity.Server.Headers["Widget-Content-Type"] = "html"; + + return VirtualClose(entity, HttpStatusCode.OK, ContentType.Html, output); + } + catch(Exception ex) + { + output.Dispose(); + + Log.Error(ex, "Failed to complie glance template"); + return VirtualClose(entity, HttpStatusCode.InternalServerError); + } + } + + private static void CompileGlanceTemplate(string hostUrl, VnMemoryStream vms, BookmarkEntry[] bookmarks) + { + using IMemoryHandle<char> buffer = MemoryUtil.SafeAlloc<char>(32 * 1024, false); + + ForwardOnlyWriter<char> writer = new(buffer.Span); + + writer.Append("<!DOCTYPE html>"); + writer.Append("<html>"); + writer.Append("<head>"); + writer.Append("<title>Simple-Bookmark Widget</title>"); + writer.Append("</head>"); + writer.Append("<body>"); + + writer.Append("<p class='size-h3'>"); + writer.Append("<a class='bookmarks-link color-highlight size-h3' href='"); + writer.Append(hostUrl); + writer.Append("'>Favorites:</a>"); + writer.Append("</p>"); + writer.Append("<hr class='margin-block-15'/>"); + + //Start bookmarks list + writer.Append("<ul class='list-horizontal-text'>"); + + foreach (BookmarkEntry entry in bookmarks) + { + writer.Append("<li>"); + + //Enable the built-in bookmarks link (makes it prettier) + writer.Append("<a class='bookmarks-link color-highlight size-h4' href='"); + writer.Append(entry.Url); + writer.Append("'>"); + writer.Append(entry.Name); + + writer.Append("</a>"); + writer.Append("</li>"); + } + + writer.Append("</ul>"); + + //Close the document + writer.Append("</body>"); + writer.Append("</html>"); + + int byteCount = Encoding.UTF8.GetByteCount(writer.AsSpan()); + + using (UnsafeMemoryHandle<byte> utf8Buffer = MemoryUtil.UnsafeAllocNearestPage(byteCount, true)) + { + //Encode utf8 bytes + Encoding.UTF8.GetBytes(writer.AsSpan(), utf8Buffer.Span); + + vms.Write(utf8Buffer.AsSpan(0, byteCount)); + } + + vms.Seek(0, System.IO.SeekOrigin.Begin); + } + } +} diff --git a/back-end/src/Model/Widget/WidgetAuthManager.cs b/back-end/src/Model/Widget/WidgetAuthManager.cs new file mode 100644 index 0000000..31e0037 --- /dev/null +++ b/back-end/src/Model/Widget/WidgetAuthManager.cs @@ -0,0 +1,152 @@ +// 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; +using System.Threading.Tasks; +using System.Collections.Generic; + +using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; +using VNLib.Utils; +using VNLib.Utils.Extensions; +using VNLib.Plugins; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Sessions; +using VNLib.Plugins.Essentials.Users; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Users; +using VNLib.Plugins.Essentials.Extensions; + +namespace SimpleBookmark.Model.Widget +{ + [ConfigurationName("widgets")] + internal sealed class WidgetAuthManager(PluginBase plugin, IConfigScope config) + { + /* + * This auth manager attempts to cache auth data by storing user-related data in + * the session, instead of hitting the database on every request. This is done to + * reduce the number of database queries and improve performance. + */ + + const string AuthTokenQueryArgName = "t"; + const string TimestampKey = "sb.w.ts"; + const string SignatureKey = "sb.w.sig"; + const string UserSigningKeyKey = "sb.w.usk"; + + private static readonly TimeSpan _authCacheValidFor = TimeSpan.FromMinutes(25); + + private readonly IUserManager users = plugin.GetOrCreateSingleton<UserManager>(); + + private readonly int SigningKeySize = config.GetValueOrDefault("key_size", static p => p.GetInt32(), 16); + + public async ValueTask<bool> IsTokenValidAsync(HttpEntity entity) + { + string? token = GetTokenString(entity); + + if (string.IsNullOrWhiteSpace(token)) + { + //No token provided + return false; + } + + if (HasExistingAuth(entity, token)) + { + return true; + } + + return await AuthorizeSession(entity, token); + } + + private static bool HasExistingAuth(HttpEntity entity, string token) + { + //New sessions cannot be trusted yet + if (entity.Session.IsNew) + { + return false; + } + + /* + * See if existing auth data exists in the session + */ + DateTimeOffset expires = GetTimestamp(in entity.Session); + + if (expires < entity.RequestedTimeUtc) + { + return false; + } + + string cachedToken = GetCachedToken(in entity.Session); + + return string.Equals(cachedToken, token, StringComparison.Ordinal); + } + + private async Task<bool> AuthorizeSession(HttpEntity entity, string token) + { + try + { + using JsonWebToken jwt = JsonWebToken.Parse(token); + using JsonDocument jwtData = jwt.GetPayload(); + + string? userId = jwtData.RootElement.GetPropString("sub"); + + if (string.IsNullOrWhiteSpace(userId)) + { + return false; + } + + using IUser? user = await users.GetUserFromIDAsync(userId, entity.EventCancellation); + + return false; + } + catch (FormatException) + { + return false; + } + } + + public void GenerateWidgetKey(IUser user) + { + /* + * A base32 number ensures easy serialization by the user-system + * instead of base64 which can cause json overhead + */ + user[UserSigningKeyKey] = RandomHash.GetRandomBase32(SigningKeySize); + } + + /// <summary> + /// Gets a value that indicates if the user has a widget signing key enabled + /// </summary> + /// <param name="user">The user instance to verify</param> + /// <returns>True if the user has a widget signing key enabled</returns> + public static bool HasSigningKey(IUser user) => !string.IsNullOrEmpty(user[UserSigningKeyKey]); + + private static DateTimeOffset GetTimestamp(ref readonly SessionInfo session) + { + long timestamp = VnEncoding.FromBase32String<long>(session[TimestampKey]); + return DateTimeOffset.FromUnixTimeSeconds(timestamp); + } + + private static void SetTimestamp(ref readonly SessionInfo session, DateTimeOffset timestamp) + => session[TimestampKey] = VnEncoding.ToBase32String(timestamp.ToUnixTimeSeconds()); + + private static string GetCachedToken(ref readonly SessionInfo session) => session[SignatureKey]; + + private static void SetSignature(ref readonly SessionInfo session, string signature) => session[SignatureKey] = signature; + + private static string? GetTokenString(HttpEntity entity) => entity.QueryArgs.GetValueOrDefault(AuthTokenQueryArgName); + + } +} diff --git a/back-end/src/SimpleBookmark.json b/back-end/src/SimpleBookmark.json index 116587d..e7448da 100644 --- a/back-end/src/SimpleBookmark.json +++ b/back-end/src/SimpleBookmark.json @@ -1,29 +1,37 @@ { //Comments are allowed - "debug": false, //Enables obnoxious debug logging + "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 } }, + "widgets": { + "enabled": true, //Enables the widgets endpoint + "path": "/widgets", //Path for the widgets endpoint + "cors-urls": "*" //CORS allowed urls + }, + //System website lookup endpoint (aka curl) "curl": { "path": "/lookup", - "exe_path": "curl", //Path to the curl executable + "exe_path": "curl", //Path to the curl executable "extra_args": [ - "--globoff", //Disables unsafe url globbing - "--no-keepalive", //Disables keepalive, uneeded for a single lookup request - "--max-filesize", "100K", //Max file size 100K - "--max-redirs", "5", //Max redirects 5 - "--location", //Follow redirects + "--globoff", //Disables unsafe url globbing + "--no-keepalive", //Disables keepalive, uneeded for a single lookup request + "--max-filesize", + "100K", //Max file size 100K + "--max-redirs", + "5", //Max redirects 5 + "--location" //Follow redirects ] }, diff --git a/back-end/src/SimpleBookmarkEntry.cs b/back-end/src/SimpleBookmarkEntry.cs index a3d9eb9..85a52d3 100644 --- a/back-end/src/SimpleBookmarkEntry.cs +++ b/back-end/src/SimpleBookmarkEntry.cs @@ -48,6 +48,7 @@ namespace SimpleBookmark this.Route<BookmarkEndpoint>(); this.Route<BmAccountEndpoint>(); this.Route<SiteLookupEndpoint>(); + this.Route<WidgetEndpoint>(); //Ensure database is created after a delay this.ObserveWork(() => this.EnsureDbCreatedAsync<SimpleBookmarkContext>(this), 1500); |