aboutsummaryrefslogtreecommitdiff
path: root/back-end
diff options
context:
space:
mode:
Diffstat (limited to 'back-end')
-rw-r--r--back-end/src/Endpoints/WidgetEndpoint.cs160
-rw-r--r--back-end/src/Model/Widget/WidgetAuthManager.cs152
-rw-r--r--back-end/src/SimpleBookmark.json30
-rw-r--r--back-end/src/SimpleBookmarkEntry.cs1
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);