aboutsummaryrefslogtreecommitdiff
path: root/back-end
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2024-05-16 22:11:33 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2024-05-16 22:11:33 -0400
commit2cbd35be45dfaa4040626eaddd9c52c368a59bb2 (patch)
tree67ef0952337aefb9441ddc39c6cb8bf54a66cbd2 /back-end
parentc8e3ca86be45be05c1f76a7dd808275a1afaccb0 (diff)
first ideas on an iframe widget for favorites
Diffstat (limited to 'back-end')
-rw-r--r--back-end/src/Endpoints/WidgetEndpoint.cs85
-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, 257 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..d72030b
--- /dev/null
+++ b/back-end/src/Endpoints/WidgetEndpoint.cs
@@ -0,0 +1,85 @@
+W// 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.Net;
+using System.Threading.Tasks;
+
+using VNLib.Plugins;
+using VNLib.Plugins.Essentials;
+using VNLib.Plugins.Essentials.Endpoints;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Essentials.Extensions;
+
+using SimpleBookmark.Model;
+using SimpleBookmark.Model.Widget;
+
+namespace SimpleBookmark.Endpoints
+{
+ [ConfigurationName("widgets")]
+ internal sealed class WidgetEndpoint : UnprotectedWebEndpoint
+ {
+ private readonly BookmarkStore bookmarks;
+ private readonly WidgetAuthManager authManager;
+ private readonly bool Enabled;
+ private readonly string? CorsAclHeaerDomains;
+
+ public WidgetEndpoint(PluginBase plugin, IConfigScope config)
+ {
+ string path = config.GetRequiredProperty("path", static p => p.GetString()!);
+ InitPathAndLog(path, plugin.Log.CreateScope("widget-endpoint"));
+
+ Enabled = config.GetValueOrDefault("enabled", static p => p.GetBoolean(), false);
+ CorsAclHeaerDomains = config.GetValueOrDefault("cors-urls", static p => p.GetString(), null);
+
+ bookmarks = plugin.GetOrCreateSingleton<BookmarkStore>();
+ authManager = plugin.GetOrCreateSingleton<WidgetAuthManager>();
+ }
+
+ 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
+ );
+
+ return VirtualCloseJson(entity, boomarks, HttpStatusCode.OK);
+ }
+ }
+}
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 13b94a5..27813d7 100644
--- a/back-end/src/SimpleBookmarkEntry.cs
+++ b/back-end/src/SimpleBookmarkEntry.cs
@@ -51,6 +51,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);