From 5588819df4f80936b7ee9c1d8529e6170cfa189f Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Wed, 10 Jun 2026 18:57:38 +0200 Subject: [PATCH] Add webhook framework and Throne wishlist plugin --- codex-guidelines | 2 + docs/webhooks.md | 46 + plugins/throne_wishlist/README.md | 63 + plugins/throne_wishlist/cmds.json | 4 + plugins/throne_wishlist/index.js | 1024 +++++++++++++++++ plugins/throne_wishlist/plugin.json | 7 + plugins/throne_wishlist/public/admin.css | 153 +++ plugins/throne_wishlist/public/admin.js | 67 ++ plugins/throne_wishlist/public/debug-modal.js | 131 +++ plugins/throne_wishlist/views/admin.ejs | 227 ++++ scripts/verify-webhooks.js | 93 ++ src/main.js | 3 +- src/services/plugins.js | 2 + src/services/webhooks.js | 376 ++++++ src/services/youtube.js | 7 + src/web/server.js | 9 +- ...mi-core-webhook-framework-patch-v0.1.0.zip | Bin 0 -> 44438 bytes .../lumi-plugin-throne_wishlist-v0.1.0.zip | Bin 0 -> 17915 bytes 18 files changed, 2212 insertions(+), 2 deletions(-) create mode 100644 docs/webhooks.md create mode 100644 plugins/throne_wishlist/README.md create mode 100644 plugins/throne_wishlist/cmds.json create mode 100644 plugins/throne_wishlist/index.js create mode 100644 plugins/throne_wishlist/plugin.json create mode 100644 plugins/throne_wishlist/public/admin.css create mode 100644 plugins/throne_wishlist/public/admin.js create mode 100644 plugins/throne_wishlist/public/debug-modal.js create mode 100644 plugins/throne_wishlist/views/admin.ejs create mode 100644 scripts/verify-webhooks.js create mode 100644 src/services/webhooks.js create mode 100644 updates/lumi-core-webhook-framework-patch-v0.1.0.zip create mode 100644 updates/lumi-plugin-throne_wishlist-v0.1.0.zip diff --git a/codex-guidelines b/codex-guidelines index 8a2b278..190077e 100644 --- a/codex-guidelines +++ b/codex-guidelines @@ -110,6 +110,8 @@ Plugins (important) - index.js exports { id, init(...) } - web.mount("/plugins/", router, navItem) to add nav entry - Plugins should avoid core edits unless explicitly requested +- Plugins receive `webhooks` for raw-body inbound endpoint registration and + outbound webhook sending. See `docs/webhooks.md`. Current notable plugins - echonomy-framework: diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..b762cf9 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,46 @@ +# Plugin Webhook Framework + +Lumi exposes a webhook service to enabled plugins as `webhooks` in their +`init` context and as `global.lumiFrameworks.webhooks`. + +## Inbound Webhooks + +```js +init({ webhooks }) { + webhooks.registerInbound({ + pluginId: "example", + endpointId: "primary", + namespace: "example", + slug: webhooks.generateSlug({ identifier: "alerts" }), + handler: async ({ rawBody, rawBodyText, parsedJson }) => { + return { status: 204 }; + } + }); +} +``` + +Endpoints are mounted below `/webhooks/:namespace/:slug`, accept `POST` by +default, and have a 256 KB request limit. Handlers receive the exact raw body as +a `Buffer` and UTF-8 text. Provider signature verification must use `rawBody` +or `rawBodyText`, never a re-serialized `parsedJson` object. + +Use `unregisterInbound({ pluginId, endpointId })` before replacing or removing +an endpoint. `generateSlug`, `buildPublicUrl`, and +`isTimestampWithinWindow` support common endpoint and replay-protection work. + +## Outbound Webhooks + +```js +const result = await webhooks.sendJson({ + pluginId: "example", + url: "https://example.test/hooks", + payload: { event: "example" }, + timeoutMs: 10000, + retries: 1 +}); +``` + +`send` supports strings, buffers, custom headers, methods, retries, and an +optional signing callback. Results include status, response headers, response +body text, duration, and a success flag. Query strings and authorization +headers are not written to Lumi logs. diff --git a/plugins/throne_wishlist/README.md b/plugins/throne_wishlist/README.md new file mode 100644 index 0000000..ea13f46 --- /dev/null +++ b/plugins/throne_wishlist/README.md @@ -0,0 +1,63 @@ +# Throne Wishlist + +This plugin receives signed Throne wishlist events through Lumi's core webhook +framework, logs every payload, deduplicates by `event_id`, and can publish +messages to configured platform destinations. + +## Setup + +1. Install the core webhook framework patch. +2. Install and enable this plugin. +3. Open **Plugins -> Throne Wishlist** as an administrator. +4. Create an endpoint with a short identifier. +5. Copy the generated `/webhooks/throne/{identifier}-{uuid}` URL into Throne's + webhook integration. + +Throne requires an HTTPS subscriber URL in production. Configure Lumi behind an +HTTPS reverse proxy and set `public_base_url` in Lumi settings when available. + +## Security and Delivery + +Throne requests are verified with the Ed25519 public key published in Throne's +current webhook documentation. Verification uses +`X-Signature-Timestamp + "." + the exact raw request body`, with a five-minute +replay window. Invalid signatures never trigger platform messages. + +Authentic duplicate `event_id` values receive a successful response without +repeating side effects. Supported events are `gift_purchased`, +`contribution_purchased`, and `gift_crowdfunded`. Unknown authentic events are +logged and acknowledged without sending messages. + +## Debug Mode + +The admin Debug modal uses a transient Server-Sent Events session. It only shows +payloads received after the session starts. Closing the modal or disconnecting +ends the session. Sessions expire after five minutes without payloads and after +30 minutes total. Debug payloads are not stored separately; all webhook payloads +are already written to Lumi's internal logs. + +## Destinations + +- Discord requires a regular guild text channel where the bot can view and send. +- Twitch uses the first connected Lumi Twitch channel. +- YouTube uses Lumi's active live-chat sender. +- Kick remains unavailable until Lumi implements a send-message capability. + +All destinations default to disabled. A failure on one destination does not +prevent delivery attempts to other enabled destinations. + +## Placeholders + +Common placeholders: + +`{contract_version}`, `{event_id}`, `{event_type}`, +`{webhook_identifier}`, `{webhook_uuid}`, `{received_at}`, +`{received_at_iso}`, `{creator_id}`, `{creator_username}`, +`{gifter_username}`, `{message}`, `{item_name}`, +`{item_thumbnail_url}`, `{price}`, `{amount}`, `{currency}`, +`{is_surprise_gift}`, `{price_display}`, `{amount_display}`, `{value}`, +`{value_display}`, and `{payload_json}`. + +Documented data fields are also available with a `data.` prefix, such as +`{data.item_name}`. Unknown placeholders remain unchanged in rendered messages. +The admin page warns when they are saved. diff --git a/plugins/throne_wishlist/cmds.json b/plugins/throne_wishlist/cmds.json new file mode 100644 index 0000000..66d1348 --- /dev/null +++ b/plugins/throne_wishlist/cmds.json @@ -0,0 +1,4 @@ +{ + "pluginId": "throne_wishlist", + "commands": [] +} diff --git a/plugins/throne_wishlist/index.js b/plugins/throne_wishlist/index.js new file mode 100644 index 0000000..779ffc2 --- /dev/null +++ b/plugins/throne_wishlist/index.js @@ -0,0 +1,1024 @@ +const crypto = require("crypto"); +const express = require("express"); +const path = require("path"); +const discord = require("discord.js"); +const { log } = require("../../src/services/logger"); +const { getPlatformStatus } = require("../../src/services/platforms"); + +const PLUGIN_ID = "throne_wishlist"; +const NAMESPACE = "throne"; +const REPLAY_WINDOW_SECONDS = 300; +const DEBUG_IDLE_MS = 300000; +const DEBUG_MAX_MS = 1800000; +const THRONE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAPXbUfxh7XL4SYUVcfhmYMIbxvtR9E9LDd8gPJ1PwSD8= +-----END PUBLIC KEY-----`; +const EVENT_TYPES = [ + "gift_purchased", + "contribution_purchased", + "gift_crowdfunded" +]; +const PLATFORMS = ["discord", "twitch", "youtube", "kick"]; +const DEFAULT_TEMPLATES = { + gift_purchased: + "🎁 {gifter_username} bought {item_name} from {creator_username}'s Throne wishlist!", + contribution_purchased: + "💝 {gifter_username} contributed {amount_display} toward {item_name} on {creator_username}'s Throne wishlist!", + gift_crowdfunded: + "🎉 {item_name} was fully crowdfunded on {creator_username}'s Throne wishlist!" +}; +const PLACEHOLDERS = new Set([ + "contract_version", + "event_id", + "event_type", + "webhook_identifier", + "webhook_uuid", + "received_at", + "received_at_iso", + "creator_id", + "creator_username", + "gifter_username", + "message", + "item_name", + "item_thumbnail_url", + "price", + "amount", + "currency", + "is_surprise_gift", + "price_display", + "amount_display", + "value", + "value_display", + "payload_json", + "data.creator_id", + "data.creator_username", + "data.gifter_username", + "data.message", + "data.item_name", + "data.item_thumbnail_url", + "data.price", + "data.amount", + "data.currency", + "data.is_surprise_gift" +]); +const debugSessions = new Map(); + +module.exports = { + id: PLUGIN_ID, + init({ + web, + webhooks, + db, + settings, + discordClient, + twitchClient, + youtubeClient + }) { + ensureTables(db); + ensureDefaults(db); + const webhookApi = webhooks || global.lumiFrameworks?.webhooks || null; + registerStoredEndpoints({ db, webhookApi, clients: { discordClient, twitchClient, youtubeClient } }); + + const router = web.createRouter(); + router.use("/assets", express.static(path.join(__dirname, "public"))); + + router.get("/", async (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + const baseUrl = getPublicBaseUrl(req, settings); + const endpoints = listEndpoints(db).map((endpoint) => ({ + ...endpoint, + publicUrl: webhookApi + ? webhookApi.buildPublicUrl({ + namespace: NAMESPACE, + slug: endpoint.slug, + req, + baseUrl: baseUrl.value + }) + : `/webhooks/${NAMESPACE}/${endpoint.slug}` + })); + const platformStatus = await buildPlatformStatus({ + settings, + db, + discordClient, + twitchClient, + youtubeClient + }); + res.render(path.join(__dirname, "views", "admin.ejs"), { + title: "Throne Wishlist", + endpoints, + templates: listTemplates(db), + destinations: listDestinations(db), + discordChannels: getDiscordChannels(discordClient), + platformStatus, + activePlatforms: platformStatus + .filter((platform) => platform.sendAvailable) + .map((platform) => platform.id), + eventTypes: EVENT_TYPES, + platforms: PLATFORMS, + placeholders: Array.from(PLACEHOLDERS).sort(), + webhookAvailable: Boolean(webhookApi), + publicBaseUrl: baseUrl.value, + publicBaseUrlWarning: baseUrl.warning, + formatTimestamp + }); + }); + + router.post("/endpoints/create", (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + if (!webhookApi) { + req.session.flash = { type: "error", message: "Core webhook framework is unavailable." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const identifier = (req.body.identifier || "").trim(); + try { + const uuid = crypto.randomUUID(); + const slug = webhookApi.generateSlug({ identifier, uuid }); + const now = Date.now(); + db.prepare( + "INSERT INTO throne_webhook_endpoints (id, identifier, slug, uuid, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)" + ).run(uuid, identifier, slug, uuid, now, now); + registerEndpoint({ + db, + webhookApi, + endpoint: getEndpoint(db, uuid), + clients: { discordClient, twitchClient, youtubeClient } + }); + req.session.flash = { type: "success", message: "Throne webhook endpoint created." }; + } catch (error) { + req.session.flash = { type: "error", message: error?.message || "Unable to create endpoint." }; + } + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/endpoints/:id/renew", (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + const endpoint = getEndpoint(db, req.params.id); + if (!endpoint || !webhookApi) { + req.session.flash = { type: "error", message: "Endpoint or webhook framework unavailable." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + try { + webhookApi.unregisterInbound({ pluginId: PLUGIN_ID, endpointId: endpoint.id }); + const uuid = crypto.randomUUID(); + const slug = webhookApi.generateSlug({ identifier: endpoint.identifier, uuid }); + db.prepare( + "UPDATE throne_webhook_endpoints SET slug = ?, uuid = ?, updated_at = ? WHERE id = ?" + ).run(slug, uuid, Date.now(), endpoint.id); + registerEndpoint({ + db, + webhookApi, + endpoint: getEndpoint(db, endpoint.id), + clients: { discordClient, twitchClient, youtubeClient } + }); + req.session.flash = { type: "success", message: "Endpoint renewed. The old URL is invalid." }; + } catch (error) { + req.session.flash = { type: "error", message: error?.message || "Unable to renew endpoint." }; + } + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/endpoints/:id/remove", (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + const endpoint = getEndpoint(db, req.params.id); + if (endpoint) { + webhookApi?.unregisterInbound({ pluginId: PLUGIN_ID, endpointId: endpoint.id }); + db.prepare("DELETE FROM throne_webhook_endpoints WHERE id = ?").run(endpoint.id); + } + req.session.flash = { type: "success", message: "Endpoint removed." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/destinations", async (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + const enabled = new Set(normalizeArray(req.body.enabled_platforms)); + const discordChannelId = (req.body.discord_destination_id || "").trim(); + if (enabled.has("discord")) { + const validation = await validateDiscordChannel(discordClient, discordChannelId); + if (!validation.valid) { + req.session.flash = { type: "error", message: validation.message }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + } + const platformStatus = await buildPlatformStatus({ + settings, + db, + discordClient, + twitchClient, + youtubeClient + }); + const statusMap = new Map(platformStatus.map((entry) => [entry.id, entry])); + for (const platform of PLATFORMS) { + const canEnable = statusMap.get(platform)?.sendAvailable; + const isEnabled = enabled.has(platform) && canEnable; + const destinationId = platform === "discord" ? discordChannelId : null; + db.prepare( + "UPDATE throne_platform_destinations SET enabled = ?, destination_id = ?, destination_label = ?, updated_at = ? WHERE platform = ?" + ).run( + isEnabled ? 1 : 0, + destinationId, + platform === "discord" ? statusMap.get(platform)?.destinationLabel || null : null, + Date.now(), + platform + ); + } + req.session.flash = { type: "success", message: "Platform destinations saved." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/templates", (req, res) => { + if (!isAdmin(req.session.user)) { + return renderDenied(res); + } + const eventType = EVENT_TYPES.includes(req.body.event_type) + ? req.body.event_type + : null; + const platform = PLATFORMS.includes(req.body.platform) + ? req.body.platform + : null; + const template = (req.body.template || "").trim(); + const enabled = req.body.enabled === "on"; + if (!eventType || !platform) { + req.session.flash = { type: "error", message: "Invalid event type or platform." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + if (enabled && !template) { + req.session.flash = { type: "error", message: "Enabled templates cannot be empty." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const unknown = findUnknownPlaceholders(template); + db.prepare( + "UPDATE throne_event_message_templates SET enabled = ?, template = ?, updated_at = ? WHERE event_type = ? AND platform = ?" + ).run(enabled ? 1 : 0, template, Date.now(), eventType, platform); + req.session.flash = unknown.length + ? { type: "error", message: `Template saved. Unknown placeholders remain unchanged: ${unknown.join(", ")}.` } + : { type: "success", message: "Event template saved." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/debug/start", (req, res) => { + if (!isAdmin(req.session.user)) { + return res.status(403).json({ error: "Access denied." }); + } + const id = crypto.randomUUID(); + const now = Date.now(); + debugSessions.set(id, { + id, + userId: req.session.user.id, + createdAt: now, + lastPayloadAt: now, + response: null, + idleTimer: setTimeout(() => closeDebugSession(id, "inactivity"), DEBUG_IDLE_MS), + expiryTimer: setTimeout(() => closeDebugSession(id, "maximum lifetime"), DEBUG_MAX_MS) + }); + res.json({ sessionId: id, expiresInSeconds: DEBUG_MAX_MS / 1000 }); + }); + + router.get("/debug/stream/:sessionId", (req, res) => { + if (!isAdmin(req.session.user)) { + return res.status(403).end(); + } + const session = debugSessions.get(req.params.sessionId); + if (!session || session.userId !== req.session.user.id) { + return res.status(404).end(); + } + res.set({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive" + }); + res.flushHeaders?.(); + session.response = res; + res.write(`event: ready\ndata: ${JSON.stringify({ sessionId: session.id })}\n\n`); + req.on("close", () => closeDebugSession(session.id, "client disconnected")); + return null; + }); + + router.post("/debug/stop/:sessionId", (req, res) => { + if (!isAdmin(req.session.user)) { + return res.status(403).json({ error: "Access denied." }); + } + const session = debugSessions.get(req.params.sessionId); + if (session?.userId === req.session.user.id) { + closeDebugSession(session.id, "stopped"); + } + res.status(204).end(); + }); + + web.mount(`/plugins/${PLUGIN_ID}`, router, { + label: "Throne Wishlist", + role: "admin", + section: "plugins" + }); + ensureSidebarNavItem(settings); + } +}; + +function ensureTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS throne_webhook_endpoints ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + uuid TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_payload_at INTEGER NULL, + last_event_id TEXT NULL, + last_event_type TEXT NULL, + last_authentic INTEGER NULL, + last_payload_preview TEXT NULL + ); + + CREATE TABLE IF NOT EXISTS throne_event_deliveries ( + event_id TEXT PRIMARY KEY, + endpoint_id TEXT NOT NULL, + event_type TEXT NOT NULL, + authentic INTEGER NOT NULL, + received_at INTEGER NOT NULL, + processed_at INTEGER NULL, + status TEXT NOT NULL, + details TEXT NULL + ); + + CREATE TABLE IF NOT EXISTS throne_event_message_templates ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + platform TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + template TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(event_type, platform) + ); + + CREATE TABLE IF NOT EXISTS throne_platform_destinations ( + platform TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + destination_id TEXT NULL, + destination_label TEXT NULL, + updated_at INTEGER NOT NULL + ); + `); +} + +function ensureDefaults(db) { + const now = Date.now(); + for (const eventType of EVENT_TYPES) { + for (const platform of PLATFORMS) { + db.prepare( + "INSERT OR IGNORE INTO throne_event_message_templates (id, event_type, platform, enabled, template, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?, ?)" + ).run(crypto.randomUUID(), eventType, platform, DEFAULT_TEMPLATES[eventType], now, now); + } + } + for (const platform of PLATFORMS) { + db.prepare( + "INSERT OR IGNORE INTO throne_platform_destinations (platform, enabled, destination_id, destination_label, updated_at) VALUES (?, 0, NULL, NULL, ?)" + ).run(platform, now); + } +} + +function registerStoredEndpoints({ db, webhookApi, clients }) { + if (!webhookApi) { + log("warn", "Throne Wishlist webhook framework unavailable"); + return; + } + for (const endpoint of listEndpoints(db).filter((entry) => entry.enabled)) { + try { + registerEndpoint({ db, webhookApi, endpoint, clients }); + } catch (error) { + log("error", "Throne endpoint registration failed", { + endpointId: endpoint.id, + message: error?.message || String(error) + }); + } + } +} + +function registerEndpoint({ db, webhookApi, endpoint, clients }) { + webhookApi.registerInbound({ + pluginId: PLUGIN_ID, + endpointId: endpoint.id, + namespace: NAMESPACE, + slug: endpoint.slug, + description: `Throne wishlist endpoint: ${endpoint.identifier}`, + handler: (context) => handleIncoming({ db, endpointId: endpoint.id, context, clients }) + }); +} + +async function handleIncoming({ + db, + endpointId, + context, + clients, + signatureVerifier = verifyThroneSignature +}) { + const endpoint = getEndpoint(db, endpointId); + if (!endpoint?.enabled) { + return { status: 404, body: { error: "Endpoint unavailable." } }; + } + const timestamp = headerValue(context.headers, "x-signature-timestamp"); + const signature = headerValue(context.headers, "x-signature-ed25519"); + const verification = signatureVerifier({ + rawBody: context.rawBody, + timestamp, + signature + }); + const payload = context.parsedJson; + const eventType = typeof payload?.event_type === "string" ? payload.event_type : null; + const eventId = typeof payload?.event_id === "string" ? payload.event_id : null; + const documented = EVENT_TYPES.includes(eventType); + let authenticityStatus = verification.status; + if (verification.authentic && context.jsonError) { + authenticityStatus = "parse_error"; + } else if (verification.authentic && eventType && !documented) { + authenticityStatus = "unknown_event"; + } + const preview = buildPayloadPreview({ + receivedAt: context.receivedAt, + eventType, + eventId, + authentic: verification.authentic, + status: authenticityStatus + }); + db.prepare( + "UPDATE throne_webhook_endpoints SET last_payload_at = ?, last_event_id = ?, last_event_type = ?, last_authentic = ?, last_payload_preview = ?, updated_at = ? WHERE id = ?" + ).run( + context.receivedAt, + eventId, + eventType, + verification.authentic ? 1 : 0, + preview, + Date.now(), + endpoint.id + ); + + const debugItem = { + endpointId: endpoint.id, + endpointIdentifier: endpoint.identifier, + webhookUuid: endpoint.uuid, + receivedAt: context.receivedAt, + eventId, + eventType, + authentic: verification.authentic, + authenticityStatus, + payload, + rawBodyPreview: context.jsonError ? context.rawBodyText.slice(0, 4000) : null + }; + publishDebug(debugItem); + writePayloadLog({ + endpoint, + context, + payload, + eventId, + eventType, + verification, + authenticityStatus + }); + + if (!verification.authentic) { + return { status: verification.httpStatus, body: { error: "Invalid Throne signature." } }; + } + if (context.jsonError || !payload) { + return { status: 400, body: { error: "Invalid JSON payload." } }; + } + if (!isValidEnvelope(payload)) { + return { status: 400, body: { error: "Invalid Throne event envelope." } }; + } + const existing = db.prepare( + "SELECT event_id FROM throne_event_deliveries WHERE event_id = ?" + ).get(eventId); + if (existing) { + log("info", `Webhook - Throne - ${EVENT_TYPES.includes(eventType) ? eventType : "unknown"}`, JSON.stringify({ + provider: "throne", + authentic: true, + authenticity_status: "duplicate", + endpoint_id: endpoint.id, + endpoint_identifier: endpoint.identifier, + event_id: eventId, + event_type: eventType, + received_at: new Date(context.receivedAt).toISOString() + })); + return { status: 204 }; + } + db.prepare( + "INSERT INTO throne_event_deliveries (event_id, endpoint_id, event_type, authentic, received_at, processed_at, status, details) VALUES (?, ?, ?, 1, ?, NULL, ?, NULL)" + ).run(eventId, endpoint.id, eventType, context.receivedAt, documented ? "accepted" : "skipped"); + if (!documented) { + return { status: 202, body: { accepted: true, message: "Unknown event logged." } }; + } + setImmediate(() => { + sendPlatformMessages({ + db, + payload, + endpoint, + receivedAt: context.receivedAt, + clients + }).catch((error) => { + log("error", "Throne platform delivery failed", { + eventId, + message: error?.message || String(error) + }); + }); + }); + return { status: 202, body: { accepted: true } }; +} + +function verifyThroneSignature({ + rawBody, + timestamp, + signature, + publicKey = THRONE_PUBLIC_KEY, + nowMs = Date.now() +}) { + if (!timestamp || !signature) { + return { authentic: false, status: "missing_signature", httpStatus: 401 }; + } + if (!/^\d+$/.test(timestamp)) { + return { authentic: false, status: "bad_timestamp", httpStatus: 400 }; + } + if (!/^[0-9a-f]{128}$/i.test(signature)) { + return { authentic: false, status: "bad_signature_length", httpStatus: 400 }; + } + if (Math.abs(nowMs - Number(timestamp) * 1000) > REPLAY_WINDOW_SECONDS * 1000) { + return { authentic: false, status: "replay_window", httpStatus: 401 }; + } + const signatureBuffer = Buffer.from(signature, "hex"); + if (signatureBuffer.length !== 64) { + return { authentic: false, status: "bad_signature_length", httpStatus: 400 }; + } + const message = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), rawBody]); + try { + const authentic = crypto.verify(null, message, publicKey, signatureBuffer); + return { + authentic, + status: authentic ? "valid" : "invalid", + httpStatus: authentic ? 204 : 401 + }; + } catch { + return { authentic: false, status: "invalid", httpStatus: 401 }; + } +} + +async function sendPlatformMessages({ db, payload, endpoint, receivedAt, clients }) { + const eventId = payload.event_id; + const tokens = buildTokens({ payload, endpoint, receivedAt }); + const rows = db.prepare( + "SELECT t.platform, t.template, d.destination_id FROM throne_event_message_templates t " + + "JOIN throne_platform_destinations d ON d.platform = t.platform " + + "WHERE t.event_type = ? AND t.enabled = 1 AND d.enabled = 1" + ).all(payload.event_type); + const tasks = rows.map(async (row) => { + const message = sanitizePlatformText(renderTemplate(row.template, tokens)); + if (!message.trim()) { + throw new Error(`Rendered ${row.platform} message is empty.`); + } + return sendToPlatform({ + platform: row.platform, + destinationId: row.destination_id, + message, + clients + }); + }); + const results = await Promise.allSettled(tasks); + const details = rows.map((row, index) => ({ + platform: row.platform, + success: results[index]?.status === "fulfilled", + error: results[index]?.status === "rejected" + ? results[index].reason?.message || String(results[index].reason) + : null + })); + const failed = details.filter((entry) => !entry.success); + const status = !details.length + ? "skipped" + : failed.length + ? (failed.length === details.length ? "failed" : "partial") + : "sent"; + db.prepare( + "UPDATE throne_event_deliveries SET processed_at = ?, status = ?, details = ? WHERE event_id = ?" + ).run( + Date.now(), + status, + JSON.stringify(details), + eventId + ); + for (const failure of failed) { + log("warn", "Throne destination send failed", { + eventId, + platform: failure.platform, + message: failure.error + }); + } +} + +async function sendToPlatform({ platform, destinationId, message, clients }) { + if (platform === "discord") { + const validation = await validateDiscordChannel(clients.discordClient, destinationId); + if (!validation.valid || !validation.channel) { + throw new Error(validation.message); + } + return validation.channel.send({ content: message, allowedMentions: { parse: [] } }); + } + if (platform === "twitch") { + if (!clients.twitchClient || typeof clients.twitchClient.say !== "function") { + throw new Error("Twitch send capability is unavailable."); + } + const channels = typeof clients.twitchClient.getChannels === "function" + ? clients.twitchClient.getChannels() + : []; + const channel = channels?.[0]; + if (!channel) { + throw new Error("No connected Twitch channel is available."); + } + return clients.twitchClient.say(channel, message); + } + if (platform === "youtube") { + if (!clients.youtubeClient || typeof clients.youtubeClient.sendMessage !== "function") { + throw new Error("YouTube send capability is unavailable."); + } + return clients.youtubeClient.sendMessage(message); + } + throw new Error(`${platform} sending is not supported by Lumi.`); +} + +function buildTokens({ payload, endpoint, receivedAt }) { + const data = payload.data || {}; + const currency = (data.currency || "").toString().toUpperCase(); + const priceDisplay = formatMoney(data.price, currency); + const amountDisplay = formatMoney(data.amount, currency); + const useAmount = payload.event_type === "contribution_purchased"; + const value = useAmount ? data.amount : data.price; + const valueDisplay = useAmount ? amountDisplay : priceDisplay; + const tokens = { + contract_version: payload.contract_version ?? "", + event_id: payload.event_id ?? "", + event_type: payload.event_type ?? "", + webhook_identifier: endpoint.identifier, + webhook_uuid: endpoint.uuid, + received_at: String(receivedAt), + received_at_iso: new Date(receivedAt).toISOString(), + creator_id: data.creator_id ?? "", + creator_username: data.creator_username ?? "", + gifter_username: data.gifter_username ?? "", + message: data.message ?? "", + item_name: data.item_name ?? "", + item_thumbnail_url: data.item_thumbnail_url ?? "", + price: data.price ?? "", + amount: data.amount ?? "", + currency, + is_surprise_gift: data.is_surprise_gift ?? "", + price_display: priceDisplay, + amount_display: amountDisplay, + value: value ?? "", + value_display: valueDisplay, + payload_json: JSON.stringify(payload) + }; + for (const [key, value] of Object.entries(data)) { + tokens[`data.${key}`] = value ?? ""; + } + return tokens; +} + +function renderTemplate(template, tokens) { + return (template || "").replace(/\{([^{}]+)\}/g, (full, key) => + Object.prototype.hasOwnProperty.call(tokens, key.trim()) + ? String(tokens[key.trim()]) + : full + ); +} + +function formatMoney(value, currency) { + const amount = Number(value); + if (!Number.isFinite(amount)) { + return ""; + } + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency || "USD" + }).format(amount / 100); + } catch { + return `${(amount / 100).toFixed(2)} ${currency}`.trim(); + } +} + +function sanitizePlatformText(value) { + return (value || "") + .toString() + .replace(/@everyone/gi, "@\u200beveryone") + .replace(/@here/gi, "@\u200bhere") + .replace(/<@&?\d+>/g, "[mention]") + .replace(/<#\d+>/g, "[channel]") + .slice(0, 1800); +} + +function isValidEnvelope(payload) { + return payload && + typeof payload.contract_version === "string" && + typeof payload.event_id === "string" && + payload.event_id.length > 0 && + typeof payload.event_type === "string" && + payload.event_type.length > 0 && + payload.data && + typeof payload.data === "object" && + !Array.isArray(payload.data); +} + +function writePayloadLog({ + endpoint, + context, + payload, + eventId, + eventType, + verification, + authenticityStatus +}) { + const logEventType = context.jsonError + ? "invalid" + : EVENT_TYPES.includes(eventType) + ? eventType + : "unknown"; + log("info", `Webhook - Throne - ${logEventType}`, JSON.stringify({ + provider: "throne", + authentic: verification.authentic, + authenticity_status: authenticityStatus, + endpoint_id: endpoint.id, + endpoint_identifier: endpoint.identifier, + event_id: eventId, + event_type: eventType, + received_at: new Date(context.receivedAt).toISOString(), + payload: payload || null, + raw_body_preview: context.jsonError ? context.rawBodyText.slice(0, 4000) : null, + signature_prefix: (headerValue(context.headers, "x-signature-ed25519") || "").slice(0, 12) + })); +} + +function buildPayloadPreview({ receivedAt, eventType, eventId, authentic, status }) { + return JSON.stringify({ + received_at: new Date(receivedAt).toISOString(), + event_type: eventType, + event_id: eventId, + authentic, + status + }); +} + +function publishDebug(item) { + const now = Date.now(); + for (const [id, session] of debugSessions.entries()) { + if (now - session.createdAt > DEBUG_MAX_MS || now - session.lastPayloadAt > DEBUG_IDLE_MS) { + closeDebugSession(id, "expired"); + continue; + } + session.lastPayloadAt = now; + clearTimeout(session.idleTimer); + session.idleTimer = setTimeout( + () => closeDebugSession(id, "inactivity"), + DEBUG_IDLE_MS + ); + if (session.response && !session.response.writableEnded) { + session.response.write(`event: payload\ndata: ${JSON.stringify(item)}\n\n`); + } + } +} + +function closeDebugSession(id, reason) { + const session = debugSessions.get(id); + if (!session) { + return; + } + clearTimeout(session.expiryTimer); + clearTimeout(session.idleTimer); + if (session.response && !session.response.writableEnded) { + session.response.write(`event: close\ndata: ${JSON.stringify({ reason })}\n\n`); + session.response.end(); + } + debugSessions.delete(id); +} + +async function buildPlatformStatus({ settings, db, discordClient, twitchClient, youtubeClient }) { + const configured = new Map(getPlatformStatus().map((entry) => [entry.id, entry])); + const destinations = new Map(listDestinations(db).map((entry) => [entry.platform, entry])); + const discordDestination = destinations.get("discord"); + const discordValidation = discordDestination?.destination_id + ? await validateDiscordChannel(discordClient, discordDestination.destination_id) + : { valid: false, message: "No Discord channel selected." }; + return PLATFORMS.map((id) => { + const platform = configured.get(id); + let sendAvailable = false; + let diagnostic = "Integration unavailable."; + if (id === "discord") { + sendAvailable = Boolean(platform?.enabled && platform?.configured && discordClient); + diagnostic = sendAvailable ? discordValidation.message : "Discord is disabled, unconfigured, or disconnected."; + } else if (id === "twitch") { + sendAvailable = Boolean(platform?.enabled && platform?.configured && twitchClient?.say); + diagnostic = sendAvailable ? "Twitch send capability available." : "Twitch is disabled, unconfigured, or disconnected."; + } else if (id === "youtube") { + sendAvailable = Boolean(platform?.enabled && platform?.configured && youtubeClient?.sendMessage); + diagnostic = sendAvailable ? "YouTube send capability available." : "YouTube is disabled, unconfigured, or has no active sender."; + } else { + diagnostic = "Kick sending is not implemented in Lumi."; + } + return { + id, + label: platform?.label || id.charAt(0).toUpperCase() + id.slice(1), + enabled: Boolean(platform?.enabled), + configured: Boolean(platform?.configured), + supported: Boolean(platform?.supported), + sendAvailable, + diagnostic, + destinationLabel: id === "discord" && discordValidation.channel + ? `${discordValidation.channel.guild?.name || "Discord"} - ${discordValidation.channel.name}` + : null + }; + }); +} + +function listEndpoints(db) { + return db.prepare( + "SELECT * FROM throne_webhook_endpoints ORDER BY created_at DESC" + ).all(); +} + +function getEndpoint(db, id) { + return db.prepare( + "SELECT * FROM throne_webhook_endpoints WHERE id = ?" + ).get(id); +} + +function listTemplates(db) { + return db.prepare( + "SELECT * FROM throne_event_message_templates ORDER BY event_type, platform" + ).all(); +} + +function listDestinations(db) { + return db.prepare( + "SELECT * FROM throne_platform_destinations ORDER BY platform" + ).all(); +} + +function getPublicBaseUrl(req, settings) { + const configured = (settings?.getSetting?.("public_base_url", "") || "").toString().trim(); + if (configured) { + return { + value: configured.replace(/\/+$/, ""), + warning: configured.startsWith("https://") + ? null + : "Configured public base URL is not HTTPS. Throne production webhooks require HTTPS." + }; + } + const forwardedProto = req.get("x-forwarded-proto"); + const protocol = forwardedProto ? forwardedProto.split(",")[0].trim() : req.protocol; + const value = `${protocol}://${req.get("host")}`; + return { + value, + warning: value.startsWith("https://") + ? null + : "No HTTPS public base URL is configured. This URL is best-effort; Throne production webhooks require HTTPS." + }; +} + +async function validateDiscordChannel(client, channelId) { + if (!client) { + return { valid: false, message: "Discord client is unavailable." }; + } + if (!channelId) { + return { valid: false, message: "Select a Discord text channel." }; + } + const channel = client.channels?.cache?.get(channelId) || + (typeof client.channels?.fetch === "function" + ? await client.channels.fetch(channelId).catch(() => null) + : null); + if (!channel || channel.isThread?.() || !(channel.type === "GUILD_TEXT" || channel.type === 0)) { + return { valid: false, message: "Discord destination must be a regular guild text channel." }; + } + const member = channel.guild?.members?.cache?.get(client.user?.id) || + (typeof channel.guild?.members?.fetch === "function" && client.user?.id + ? await channel.guild.members.fetch(client.user.id).catch(() => null) + : null); + const permissions = member ? channel.permissionsFor(member) : null; + if (permissions && !hasDiscordPermission(permissions, "VIEW_CHANNEL", "ViewChannel")) { + return { valid: false, message: "The bot cannot view the selected Discord channel.", channel }; + } + if (permissions && !hasDiscordPermission(permissions, "SEND_MESSAGES", "SendMessages")) { + return { valid: false, message: "The bot cannot send messages in the selected Discord channel.", channel }; + } + return { valid: true, message: "Discord channel is valid.", channel }; +} + +function hasDiscordPermission(permissions, legacyName, modernName) { + const value = + discord.Permissions?.FLAGS?.[legacyName] || + discord.PermissionFlagsBits?.[modernName] || + legacyName; + try { + return Boolean(permissions.has(value)); + } catch { + return false; + } +} + +function getDiscordChannels(client) { + const channels = []; + if (!client?.guilds?.cache) { + return channels; + } + for (const guild of client.guilds.cache.values()) { + guild.channels?.cache?.forEach((channel) => { + if (!channel.isThread?.() && (channel.type === "GUILD_TEXT" || channel.type === 0)) { + channels.push({ id: channel.id, label: `${guild.name} - ${channel.name}` }); + } + }); + } + return channels.sort((a, b) => a.label.localeCompare(b.label)); +} + +function ensureSidebarNavItem(settings) { + if (!settings?.getSetting || !settings?.setSetting) { + return; + } + const navId = "plugins_throne_wishlist"; + const raw = settings.getSetting("nav_structure", null); + if (!raw) { + return; + } + let structure = raw; + if (typeof structure === "string") { + try { + structure = JSON.parse(structure); + } catch { + return; + } + } + if (!structure?.enabled || !Array.isArray(structure.sections)) { + return; + } + for (const section of structure.sections) { + if (Array.isArray(section.items)) { + section.items = section.items.filter((item) => item !== navId); + } + } + let pluginsSection = structure.sections.find((section) => section.id === "plugins"); + if (!pluginsSection) { + pluginsSection = { id: "plugins", label: "Plugins", icon: "blocks", items: [] }; + structure.sections.push(pluginsSection); + } + pluginsSection.items = Array.isArray(pluginsSection.items) ? pluginsSection.items : []; + pluginsSection.items.push(navId); + settings.setSetting("nav_structure", structure); +} + +function findUnknownPlaceholders(template) { + const unknown = []; + for (const match of (template || "").matchAll(/\{([^{}]+)\}/g)) { + const key = match[1].trim(); + if (!PLACEHOLDERS.has(key) && !unknown.includes(`{${key}}`)) { + unknown.push(`{${key}}`); + } + } + return unknown; +} + +function headerValue(headers, name) { + const value = headers?.[name.toLowerCase()]; + return Array.isArray(value) ? value[0] : (value || "").toString(); +} + +function normalizeArray(value) { + if (Array.isArray(value)) { + return value; + } + return value ? [value] : []; +} + +function formatTimestamp(value) { + return value ? new Date(value).toLocaleString() : "Never"; +} + +function isAdmin(user) { + return Boolean(user?.isAdmin); +} + +function renderDenied(res) { + return res.status(403).render("error", { + title: "Access denied", + message: "Administrator access is required." + }); +} + +module.exports.verifyThroneSignature = verifyThroneSignature; +module.exports.handleIncoming = handleIncoming; +module.exports.buildTokens = buildTokens; +module.exports.renderTemplate = renderTemplate; +module.exports.getDiscordChannels = getDiscordChannels; diff --git a/plugins/throne_wishlist/plugin.json b/plugins/throne_wishlist/plugin.json new file mode 100644 index 0000000..83e2de8 --- /dev/null +++ b/plugins/throne_wishlist/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "throne_wishlist", + "name": "Throne Wishlist", + "version": "0.1.0", + "description": "Throne wishlist webhook integration with verified payloads, debug viewer, and cross-platform event messages.", + "main": "index.js" +} diff --git a/plugins/throne_wishlist/public/admin.css b/plugins/throne_wishlist/public/admin.css new file mode 100644 index 0000000..2b8b5b8 --- /dev/null +++ b/plugins/throne_wishlist/public/admin.css @@ -0,0 +1,153 @@ +.throne-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.throne-header h1, +.throne-header p { + margin: 0; +} + +.diagnostic-grid, +.destination-grid, +.template-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.diagnostic-grid > div, +.destination-panel, +.template-panel { + border: 1px solid var(--border); + background: var(--surface-2); + padding: 14px; + border-radius: 8px; +} + +.diagnostic-grid span, +.diagnostic-grid small, +.destination-panel small { + display: block; + color: var(--ink-soft); +} + +.inline-form, +.copy-row, +.action-row, +.template-heading, +.debug-toolbar { + display: flex; + align-items: end; + gap: 10px; +} + +.inline-form .field, +.copy-row input, +.debug-toolbar input { + flex: 1; +} + +.throne-table code { + word-break: break-all; +} + +.throne-table td small { + display: block; +} + +.copy-status { + min-height: 1.2em; +} + +.destination-panel { + min-width: 0; +} + +.destination-panel legend { + font-weight: 700; +} + +.destination-panel[disabled] { + opacity: 0.62; +} + +.placeholder-list { + margin-bottom: 18px; +} + +.placeholder-list summary { + cursor: pointer; + font-weight: 700; +} + +.placeholder-list div { + margin-top: 10px; + line-height: 2; +} + +.event-sections { + display: grid; + gap: 20px; +} + +.event-section { + border-top: 1px solid var(--border); + padding-top: 12px; +} + +.template-panel { + display: grid; + gap: 10px; +} + +.template-heading { + justify-content: space-between; + align-items: center; +} + +.button.danger { + background: var(--rose); + color: white; +} + +.debug-modal { + width: min(900px, calc(100vw - 32px)); +} + +.debug-toolbar { + align-items: center; + margin-bottom: 12px; +} + +.debug-summary { + display: grid; + gap: 4px; + margin-bottom: 12px; +} + +.debug-json { + max-height: 52vh; + overflow: auto; + padding: 14px; + border: 1px solid var(--border); + background: var(--surface-3); + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 720px) { + .throne-header, + .inline-form, + .action-row { + align-items: stretch; + flex-direction: column; + } + + .throne-table { + min-width: 900px; + } +} diff --git a/plugins/throne_wishlist/public/admin.js b/plugins/throne_wishlist/public/admin.js new file mode 100644 index 0000000..1bd0959 --- /dev/null +++ b/plugins/throne_wishlist/public/admin.js @@ -0,0 +1,67 @@ +(() => { + document.querySelectorAll("[data-copy-button]").forEach((button) => { + button.addEventListener("click", async () => { + const id = button.dataset.copyButton; + const input = document.querySelector(`[data-copy-source="${id}"]`); + const status = document.querySelector(`[data-copy-status="${id}"]`); + if (!input) return; + try { + await navigator.clipboard.writeText(input.value); + if (status) status.textContent = "Copied."; + } catch { + input.focus(); + input.select(); + const copied = document.execCommand("copy"); + if (status) status.textContent = copied ? "Copied." : "Select and copy the URL manually."; + } + }); + }); + + const modal = document.querySelector("[data-confirm-modal]"); + const form = modal?.querySelector("[data-confirm-form]"); + const heading = modal?.querySelector("[data-confirm-heading]"); + const description = modal?.querySelector("[data-confirm-description]"); + const submit = modal?.querySelector("[data-confirm-submit]"); + let timer = null; + + const close = () => { + if (!modal) return; + clearInterval(timer); + modal.classList.remove("is-open"); + modal.setAttribute("aria-hidden", "true"); + }; + + document.querySelectorAll("[data-confirm-open]").forEach((button) => { + button.addEventListener("click", () => { + if (!modal || !form || !submit) return; + form.action = button.dataset.confirmAction; + heading.textContent = button.dataset.confirmTitle || "Confirm action"; + description.textContent = button.dataset.confirmText || ""; + let remaining = 3; + submit.disabled = true; + submit.textContent = `Confirm in ${remaining}`; + modal.classList.add("is-open"); + modal.setAttribute("aria-hidden", "false"); + timer = setInterval(() => { + remaining -= 1; + if (remaining <= 0) { + clearInterval(timer); + submit.disabled = false; + submit.textContent = "Confirm"; + submit.focus(); + } else { + submit.textContent = `Confirm in ${remaining}`; + } + }, 1000); + }); + }); + modal?.querySelectorAll("[data-confirm-close]").forEach((button) => { + button.addEventListener("click", close); + }); + modal?.addEventListener("click", (event) => { + if (event.target === modal) close(); + }); + window.addEventListener("keydown", (event) => { + if (event.key === "Escape" && modal?.classList.contains("is-open")) close(); + }); +})(); diff --git a/plugins/throne_wishlist/public/debug-modal.js b/plugins/throne_wishlist/public/debug-modal.js new file mode 100644 index 0000000..c2c136e --- /dev/null +++ b/plugins/throne_wishlist/public/debug-modal.js @@ -0,0 +1,131 @@ +(() => { + const modal = document.querySelector("[data-debug-modal]"); + const openButton = document.querySelector("[data-debug-open]"); + const closeButton = modal?.querySelector("[data-debug-close]"); + const status = modal?.querySelector("[data-debug-status]"); + const page = modal?.querySelector("[data-debug-page]"); + const slider = modal?.querySelector("[data-debug-slider]"); + const previous = modal?.querySelector("[data-debug-prev]"); + const next = modal?.querySelector("[data-debug-next]"); + const summary = modal?.querySelector("[data-debug-summary]"); + const json = modal?.querySelector("[data-debug-json]"); + const items = []; + let current = -1; + let sessionId = null; + let source = null; + + const render = () => { + const total = items.length; + if (!total) { + current = -1; + page.textContent = "0 / 0"; + slider.min = "1"; + slider.max = "1"; + slider.value = "1"; + slider.disabled = true; + previous.disabled = true; + next.disabled = true; + summary.textContent = "No payloads received during this session."; + json.textContent = "{}"; + return; + } + current = Math.max(0, Math.min(current, total - 1)); + const item = items[current]; + page.textContent = `${current + 1} / ${total}`; + slider.min = "1"; + slider.max = String(total); + slider.value = String(current + 1); + slider.disabled = false; + previous.disabled = current === 0; + next.disabled = current === total - 1; + summary.innerHTML = ""; + [ + `Endpoint: ${item.endpointIdentifier}`, + `Event: ${item.eventType || "unknown"}`, + `Event ID: ${item.eventId || "none"}`, + `Authenticity: ${item.authentic ? "valid" : item.authenticityStatus}` + ].forEach((text) => { + const line = document.createElement("div"); + line.textContent = text; + summary.appendChild(line); + }); + json.textContent = JSON.stringify(item.payload || { + raw_body_preview: item.rawBodyPreview + }, null, 2); + }; + + const stop = async () => { + source?.close(); + source = null; + if (sessionId) { + navigator.sendBeacon?.(`/plugins/throne_wishlist/debug/stop/${sessionId}`); + sessionId = null; + } + }; + + const close = async () => { + await stop(); + modal.classList.remove("is-open"); + modal.setAttribute("aria-hidden", "true"); + }; + + const open = async () => { + items.length = 0; + current = -1; + render(); + modal.classList.add("is-open"); + modal.setAttribute("aria-hidden", "false"); + status.textContent = "Starting debug session…"; + try { + const response = await fetch("/plugins/throne_wishlist/debug/start", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" } + }); + if (!response.ok) throw new Error("Unable to start debug session."); + const data = await response.json(); + sessionId = data.sessionId; + source = new EventSource(`/plugins/throne_wishlist/debug/stream/${sessionId}`); + source.addEventListener("ready", () => { + status.textContent = "Listening for new Throne payloads."; + }); + source.addEventListener("payload", (event) => { + items.push(JSON.parse(event.data)); + current = items.length - 1; + status.textContent = "Payload received."; + render(); + }); + source.addEventListener("close", (event) => { + const data = JSON.parse(event.data || "{}"); + status.textContent = `Debug session ended: ${data.reason || "closed"}.`; + source?.close(); + }); + source.onerror = () => { + status.textContent = "Debug stream disconnected."; + }; + } catch (error) { + status.textContent = error.message; + } + }; + + openButton?.addEventListener("click", open); + closeButton?.addEventListener("click", close); + modal?.addEventListener("click", (event) => { + if (event.target === modal) close(); + }); + previous?.addEventListener("click", () => { + current -= 1; + render(); + }); + next?.addEventListener("click", () => { + current += 1; + render(); + }); + slider?.addEventListener("input", () => { + current = Number(slider.value) - 1; + render(); + }); + window.addEventListener("keydown", (event) => { + if (event.key === "Escape" && modal?.classList.contains("is-open")) close(); + }); + window.addEventListener("beforeunload", stop); +})(); diff --git a/plugins/throne_wishlist/views/admin.ejs b/plugins/throne_wishlist/views/admin.ejs new file mode 100644 index 0000000..c9593a5 --- /dev/null +++ b/plugins/throne_wishlist/views/admin.ejs @@ -0,0 +1,227 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> + + +<% const destinationMap = new Map(destinations.map((item) => [item.platform, item])); %> +<% const templateMap = new Map(templates.map((item) => [item.event_type + ":" + item.platform, item])); %> +<% const statusMap = new Map(platformStatus.map((item) => [item.id, item])); %> + +
+
+

Throne Wishlist

+

Verified wishlist events and cross-platform notifications.

+
+ +
+ +
+
+
+

Diagnostics

+

Core availability and public delivery readiness.

+
+
+
+
Webhook framework<%= webhookAvailable ? "Available" : "Unavailable" %>
+
Public base URL<%= publicBaseUrl %>
+ <% platformStatus.forEach((platform) => { %> +
<%= platform.label %><%= platform.sendAvailable ? "Ready" : "Unavailable" %><%= platform.diagnostic %>
+ <% }) %> +
+ <% if (publicBaseUrlWarning) { %> +
<%= publicBaseUrlWarning %>
+ <% } %> + <% if (!webhookAvailable) { %> +
Install and restart with the core webhook framework patch before creating endpoints.
+ <% } %> +
+ +
+
+
+

Endpoints

+

Create multiple subscriber URLs and paste them into Throne.

+
+
+
+
+ + +
+ +
+ + <% if (!endpoints.length) { %> +

No Throne endpoints configured.

+ <% } else { %> +
+ + + + + + + + + + + + <% endpoints.forEach((endpoint) => { %> + + + + + + + + <% }) %> + +
IdentifierUUIDLinkLast payloadActions
<%= endpoint.identifier %><%= endpoint.uuid %> +
+ + +
+ +
+ <% if (endpoint.last_payload_preview) { %> + <% let preview = {}; try { preview = JSON.parse(endpoint.last_payload_preview); } catch {} %> + <%= preview.event_type || "unknown" %> + <%= preview.event_id || "No event id" %> + <%= preview.authentic ? "Authentic" : "Rejected" %> · <%= formatTimestamp(endpoint.last_payload_at) %> + <% } else { %> + Never + <% } %> + +
+ + +
+
+
+ <% } %> +
+ +
+
+
+

Platform Destinations

+

Only destinations with a working Lumi send capability can be enabled.

+
+
+
+ <% const availablePlatforms = platformStatus.filter((platform) => platform.sendAvailable); %> + <% if (!availablePlatforms.length) { %> +

No active platform sender is currently available.

+ <% } %> + <% availablePlatforms.forEach((platform) => { const destination = destinationMap.get(platform.id); %> +
+ <%= platform.label %> + + <% if (platform.id === "discord") { %> +
+ + +
+ <% } %> + <%= platform.diagnostic %> +
+ <% }) %> +
+ +
+
+
+ +
+
+
+

Event Messages

+

One message per event and platform. Unknown placeholders remain unchanged.

+
+
+
+ Available placeholders +
+ <% placeholders.forEach((placeholder) => { %>{<%= placeholder %>} <% }) %> +
+
+
+ <% eventTypes.forEach((eventType) => { %> +
+

<%= eventType %>

+
+ <% if (!activePlatforms.length) { %> +

No active platform templates are available.

+ <% } %> + <% activePlatforms.forEach((platform) => { const template = templateMap.get(eventType + ":" + platform); const status = statusMap.get(platform); %> +
+ + +
+ <%= status?.label || platform %> + +
+ + + +
+ <% }) %> +
+
+ <% }) %> +
+
+ + + + + + + +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/scripts/verify-webhooks.js b/scripts/verify-webhooks.js new file mode 100644 index 0000000..8f9d0b0 --- /dev/null +++ b/scripts/verify-webhooks.js @@ -0,0 +1,93 @@ +const assert = require("assert"); +const http = require("http"); +const express = require("express"); +const { + createWebhookService, + generateSlug, + sanitizeIdentifier, + isTimestampWithinWindow +} = require("../src/services/webhooks"); + +async function main() { + assert.strictEqual(sanitizeIdentifier(" Cozy Alerts! "), "cozy-alerts"); + assert.strictEqual( + generateSlug({ + identifier: "Cozy Alerts", + uuid: "123e4567-e89b-42d3-a456-426614174000" + }), + "cozy-alerts-123e4567-e89b-42d3-a456-426614174000" + ); + assert.throws(() => sanitizeIdentifier("../")); + assert.strictEqual( + isTimestampWithinWindow(String(Math.floor(Date.now() / 1000))), + true + ); + + const service = createWebhookService(); + const app = express(); + app.use("/webhooks", service.router); + let observed = null; + service.registerInbound({ + pluginId: "verification", + endpointId: "raw-body", + namespace: "test", + slug: "raw-body", + handler: async (context) => { + observed = context; + return { status: 202, body: { accepted: true } }; + } + }); + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const raw = '{"z":1, "a":2}'; + const response = await fetch( + `http://127.0.0.1:${server.address().port}/webhooks/test/raw-body`, + { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: raw + } + ); + assert.strictEqual(response.status, 202); + assert.strictEqual(observed.rawBodyText, raw); + assert.deepStrictEqual(observed.parsedJson, { z: 1, a: 2 }); + const methodResponse = await fetch( + `http://127.0.0.1:${server.address().port}/webhooks/test/raw-body` + ); + assert.strictEqual(methodResponse.status, 405); + const unknownResponse = await fetch( + `http://127.0.0.1:${server.address().port}/webhooks/test/unknown`, + { method: "POST", body: "{}" } + ); + assert.strictEqual(unknownResponse.status, 404); + + const receiver = http.createServer((req, res) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + res.writeHead(201, { "Content-Type": "text/plain" }); + res.end(body); + }); + }); + await new Promise((resolve) => receiver.listen(0, "127.0.0.1", resolve)); + const outbound = await service.sendJson({ + pluginId: "verification", + url: `http://127.0.0.1:${receiver.address().port}/receive`, + payload: { hello: "world" } + }); + assert.strictEqual(outbound.status, 201); + assert.strictEqual(outbound.success, true); + assert.strictEqual(outbound.body, '{"hello":"world"}'); + + await new Promise((resolve) => server.close(resolve)); + await new Promise((resolve) => receiver.close(resolve)); + console.log("Webhook framework verification passed."); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/main.js b/src/main.js index 3b4a5fc..6cc4b31 100644 --- a/src/main.js +++ b/src/main.js @@ -49,7 +49,7 @@ async function main() { const app = createWebServer({ discordClient, - loadPlugins: (appInstance, web) => { + loadPlugins: (appInstance, web, webhooks) => { loadEnabled({ app: appInstance, discordClient, @@ -57,6 +57,7 @@ async function main() { youtubeClient, settings: settingsApi, web, + webhooks, commandRouter }); } diff --git a/src/services/plugins.js b/src/services/plugins.js index 18d8622..32d02b8 100644 --- a/src/services/plugins.js +++ b/src/services/plugins.js @@ -96,6 +96,7 @@ function loadEnabled({ youtubeClient, settings, web, + webhooks, commandRouter }) { const installed = scanPluginDirectories(); @@ -126,6 +127,7 @@ function loadEnabled({ youtubeClient, settings, web, + webhooks, db, plugin, commandRouter diff --git a/src/services/webhooks.js b/src/services/webhooks.js new file mode 100644 index 0000000..867de6f --- /dev/null +++ b/src/services/webhooks.js @@ -0,0 +1,376 @@ +const crypto = require("crypto"); +const express = require("express"); +const { log } = require("./logger"); + +function createWebhookService({ limit = "256kb" } = {}) { + const endpoints = new Map(); + const endpointKeysByPlugin = new Map(); + const router = express.Router(); + + router.use(express.raw({ type: "*/*", limit })); + router.all("/:namespace/:slug", async (req, res) => { + const namespace = normalizeSegment(req.params.namespace); + const slug = normalizeSegment(req.params.slug); + const endpoint = namespace && slug + ? endpoints.get(`${namespace}/${slug}`) + : null; + if (!endpoint) { + return res.status(404).json({ error: "Webhook endpoint not found." }); + } + if (!endpoint.methods.includes(req.method.toUpperCase())) { + res.set("Allow", endpoint.methods.join(", ")); + return res.status(405).json({ error: "Method not allowed." }); + } + + const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || ""); + const rawBodyText = rawBody.toString("utf8"); + let parsedJson = null; + let jsonError = null; + if (rawBody.length) { + try { + parsedJson = JSON.parse(rawBodyText); + } catch (error) { + jsonError = error; + } + } + const context = { + req, + headers: { ...req.headers }, + method: req.method, + namespace, + slug, + rawBody, + rawBodyText, + parsedJson, + jsonError, + receivedAt: Date.now(), + remoteAddress: req.ip || req.socket?.remoteAddress || null, + userAgent: req.get("user-agent") || null + }; + + try { + if (endpoint.verify) { + const verification = await endpoint.verify(context); + if (verification === false || verification?.ok === false) { + return res.status(Number(verification?.status) || 401).json({ + error: verification?.message || "Webhook verification failed." + }); + } + context.verification = verification || { ok: true }; + } + return sendHandlerResult(res, await endpoint.handler(context)); + } catch (error) { + log("error", "Webhook handler failed", { + pluginId: endpoint.pluginId, + endpointId: endpoint.endpointId, + namespace, + slug, + message: error?.message || String(error), + stack: error?.stack || "" + }); + if (!res.headersSent) { + return res.status(500).json({ error: "Webhook processing failed." }); + } + return null; + } + }); + router.use((error, req, res, _next) => { + const status = error?.type === "entity.too.large" ? 413 : 400; + log("warn", "Webhook request rejected", { + path: req.path, + status, + message: error?.message || String(error) + }); + res.status(status).json({ + error: status === 413 ? "Webhook payload is too large." : "Invalid webhook request." + }); + }); + + function registerInbound({ + pluginId, + namespace, + endpointId, + slug, + description, + handler, + verify, + options = {} + }) { + const safePluginId = requireValue(pluginId, "pluginId"); + const safeEndpointId = requireValue(endpointId, "endpointId"); + const safeNamespace = requireSegment(namespace, "namespace"); + const safeSlug = requireSegment(slug, "slug"); + if (typeof handler !== "function") { + throw new Error("Webhook handler must be a function."); + } + const key = `${safeNamespace}/${safeSlug}`; + if (endpoints.has(key)) { + throw new Error(`Webhook endpoint already registered: ${key}`); + } + unregisterInbound({ pluginId: safePluginId, endpointId: safeEndpointId }); + const endpoint = { + pluginId: safePluginId, + endpointId: safeEndpointId, + namespace: safeNamespace, + slug: safeSlug, + description: (description || "").toString(), + handler, + verify: typeof verify === "function" ? verify : null, + methods: normalizeMethods(options.methods), + options + }; + endpoints.set(key, endpoint); + if (!endpointKeysByPlugin.has(safePluginId)) { + endpointKeysByPlugin.set(safePluginId, new Map()); + } + endpointKeysByPlugin.get(safePluginId).set(safeEndpointId, key); + log("info", "Webhook endpoint registered", { + pluginId: safePluginId, + endpointId: safeEndpointId, + path: `/webhooks/${key}` + }); + return { namespace: safeNamespace, slug: safeSlug, path: `/webhooks/${key}` }; + } + + function unregisterInbound({ pluginId, endpointId }) { + const pluginEndpoints = endpointKeysByPlugin.get((pluginId || "").toString()); + const key = pluginEndpoints?.get((endpointId || "").toString()); + if (!key) { + return false; + } + endpoints.delete(key); + pluginEndpoints.delete((endpointId || "").toString()); + if (!pluginEndpoints.size) { + endpointKeysByPlugin.delete((pluginId || "").toString()); + } + log("debug", "Webhook endpoint unregistered", { pluginId, endpointId }); + return true; + } + + function buildPublicUrl({ namespace, slug, req, baseUrl }) { + const safeNamespace = requireSegment(namespace, "namespace"); + const safeSlug = requireSegment(slug, "slug"); + const origin = (baseUrl || requestOrigin(req) || "").replace(/\/+$/, ""); + const routePath = `/webhooks/${safeNamespace}/${safeSlug}`; + return origin ? `${origin}${routePath}` : routePath; + } + + return { + router, + registerInbound, + unregisterInbound, + buildPublicUrl, + generateSlug, + send: sendWebhook, + sendJson: ({ payload, ...options }) => sendWebhook({ ...options, json: payload }), + isTimestampWithinWindow, + getRegisteredEndpoints: () => + Array.from(endpoints.values()).map(({ handler, verify, ...entry }) => entry) + }; +} + +async function sendWebhook({ + pluginId, + url, + method = "POST", + headers = {}, + json, + body, + timeoutMs = 10000, + retries = 0, + sign +}) { + if (!url) { + throw new Error("Webhook URL is required."); + } + if (json !== undefined && body !== undefined) { + throw new Error("Provide either json or body, not both."); + } + const requestHeaders = { ...headers }; + let requestBody = body; + if (json !== undefined) { + requestBody = JSON.stringify(json); + if (!hasHeader(requestHeaders, "content-type")) { + requestHeaders["Content-Type"] = "application/json"; + } + } + if (typeof sign === "function") { + const signedHeaders = await sign({ + method, + url, + headers: { ...requestHeaders }, + body: requestBody + }); + if (signedHeaders && typeof signedHeaders === "object") { + Object.assign(requestHeaders, signedHeaders); + } + } + + const attempts = Math.max(1, Number(retries) + 1); + let lastError = null; + let lastDurationMs = 0; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const startedAt = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs))); + try { + const response = await fetch(url, { + method, + headers: requestHeaders, + body: requestBody, + signal: controller.signal + }); + const result = { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: await response.text(), + durationMs: Date.now() - startedAt, + success: response.ok + }; + if (response.ok || attempt === attempts) { + if (!response.ok) { + log("warn", "Outbound webhook returned an error", { + pluginId: pluginId || null, + url: redactUrl(url), + status: response.status, + attempt + }); + } + return result; + } + } catch (error) { + lastError = error; + lastDurationMs = Date.now() - startedAt; + if (attempt === attempts) { + log("error", "Outbound webhook failed", { + pluginId: pluginId || null, + url: redactUrl(url), + attempt, + message: error?.message || String(error) + }); + } + } finally { + clearTimeout(timer); + } + } + return { + status: 0, + headers: {}, + body: "", + durationMs: lastDurationMs, + success: false, + error: lastError?.message || "Webhook request failed." + }; +} + +function generateSlug({ identifier, uuid = crypto.randomUUID() }) { + return `${sanitizeIdentifier(identifier)}-${requireUuid(uuid)}`; +} + +function sanitizeIdentifier(value) { + const safe = (value || "") + .toString() + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9_-]+/g, "") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + if (!safe) { + throw new Error("Webhook identifier must contain URL-safe letters or numbers."); + } + return safe; +} + +function requireUuid(value) { + const uuid = (value || "").toString().toLowerCase(); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(uuid)) { + throw new Error("Webhook UUID is invalid."); + } + return uuid; +} + +function requireSegment(value, label) { + const normalized = normalizeSegment(value); + if (!normalized) { + throw new Error(`Webhook ${label} is invalid.`); + } + return normalized; +} + +function normalizeSegment(value) { + const raw = (value || "").toString().trim().toLowerCase(); + if (!raw || raw.includes("/") || raw.includes("\\") || raw.includes("..")) { + return null; + } + if (/%2f|%5c/i.test(raw)) { + return null; + } + return /^[a-z0-9][a-z0-9_-]*$/.test(raw) ? raw : null; +} + +function requireValue(value, label) { + const safe = (value || "").toString().trim(); + if (!safe) { + throw new Error(`Webhook ${label} is required.`); + } + return safe; +} + +function normalizeMethods(methods) { + const source = Array.isArray(methods) && methods.length ? methods : ["POST"]; + return Array.from(new Set(source.map((method) => method.toString().toUpperCase()))); +} + +function sendHandlerResult(res, result) { + const status = Number(result?.status) || 204; + if (result?.headers && typeof result.headers === "object") { + res.set(result.headers); + } + if (result?.body === undefined || result?.body === null) { + return res.status(status).end(); + } + if (Buffer.isBuffer(result.body) || typeof result.body === "string") { + return res.status(status).send(result.body); + } + return res.status(status).json(result.body); +} + +function requestOrigin(req) { + if (!req) { + return ""; + } + const forwardedProto = req.get?.("x-forwarded-proto"); + const protocol = forwardedProto ? forwardedProto.split(",")[0].trim() : req.protocol; + const host = req.get?.("host"); + return protocol && host ? `${protocol}://${host}` : ""; +} + +function isTimestampWithinWindow(timestamp, windowSeconds = 300, nowMs = Date.now()) { + if (!/^\d+$/.test((timestamp || "").toString())) { + return false; + } + const timestampMs = Number(timestamp) * 1000; + return Number.isFinite(timestampMs) && + Math.abs(nowMs - timestampMs) <= Math.max(0, Number(windowSeconds)) * 1000; +} + +function hasHeader(headers, name) { + return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase()); +} + +function redactUrl(value) { + try { + const url = new URL(value); + return `${url.protocol}//${url.host}${url.pathname}`; + } catch { + return "[invalid-url]"; + } +} + +module.exports = { + createWebhookService, + generateSlug, + sanitizeIdentifier, + isTimestampWithinWindow +}; diff --git a/src/services/youtube.js b/src/services/youtube.js index 5a2c876..bbc9bd8 100644 --- a/src/services/youtube.js +++ b/src/services/youtube.js @@ -28,6 +28,13 @@ async function startYouTubeBot({ commandRouter } = {}) { stopped: false, commandRouter }; + state.sendMessage = async (content) => { + const liveChatId = await ensureLiveChatId(state); + if (!liveChatId) { + throw new Error("No active YouTube live chat is available."); + } + return sendChatMessage(state, liveChatId, content); + }; client = state; diff --git a/src/web/server.js b/src/web/server.js index 784556b..a84c8f4 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -33,6 +33,7 @@ const { getTopCommandOptions } = require("../services/top"); const { log, listLogs } = require("../services/logger"); +const { createWebhookService } = require("../services/webhooks"); const { getPlatformStatus, getEnabledPlatformIds, @@ -1729,6 +1730,11 @@ async function verifyYouTubeSettings(settings) { function createWebServer({ loadPlugins, discordClient }) { const app = express(); + const webhooks = createWebhookService(); + if (!global.lumiFrameworks) { + global.lumiFrameworks = {}; + } + global.lumiFrameworks.webhooks = webhooks; const assetVersion = Date.now().toString(); const sessionStore = new BetterSqlite3Store({ client: db @@ -1736,6 +1742,7 @@ function createWebServer({ loadPlugins, discordClient }) { app.set("view engine", "ejs"); app.set("views", path.join(__dirname, "views")); + app.use("/webhooks", webhooks.router); const originalAppRender = app.render.bind(app); app.render = (view, options, callback) => { @@ -4423,7 +4430,7 @@ function createWebServer({ loadPlugins, discordClient }) { }); if (typeof loadPlugins === "function") { - loadPlugins(app, web); + loadPlugins(app, web, webhooks); } return app; diff --git a/updates/lumi-core-webhook-framework-patch-v0.1.0.zip b/updates/lumi-core-webhook-framework-patch-v0.1.0.zip new file mode 100644 index 0000000000000000000000000000000000000000..55036969d04c9742b020a66b56eb51ec071b68eb GIT binary patch literal 44438 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 V!NUFR#lO8g4g~?xQ1*|?DOK!h|HPsMdbMA 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 W2yQql8z?bx17R<)9~0pO;sF4Ep440b literal 0 HcmV?d00001