Add webhook framework and Throne wishlist plugin
This commit is contained in:
parent
6af21664e8
commit
5588819df4
@ -110,6 +110,8 @@ Plugins (important)
|
||||
- index.js exports { id, init(...) }
|
||||
- web.mount("/plugins/<id>", 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:
|
||||
|
||||
46
docs/webhooks.md
Normal file
46
docs/webhooks.md
Normal file
@ -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.
|
||||
63
plugins/throne_wishlist/README.md
Normal file
63
plugins/throne_wishlist/README.md
Normal file
@ -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.
|
||||
4
plugins/throne_wishlist/cmds.json
Normal file
4
plugins/throne_wishlist/cmds.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"pluginId": "throne_wishlist",
|
||||
"commands": []
|
||||
}
|
||||
1024
plugins/throne_wishlist/index.js
Normal file
1024
plugins/throne_wishlist/index.js
Normal file
File diff suppressed because it is too large
Load Diff
7
plugins/throne_wishlist/plugin.json
Normal file
7
plugins/throne_wishlist/plugin.json
Normal file
@ -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"
|
||||
}
|
||||
153
plugins/throne_wishlist/public/admin.css
Normal file
153
plugins/throne_wishlist/public/admin.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
67
plugins/throne_wishlist/public/admin.js
Normal file
67
plugins/throne_wishlist/public/admin.js
Normal file
@ -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();
|
||||
});
|
||||
})();
|
||||
131
plugins/throne_wishlist/public/debug-modal.js
Normal file
131
plugins/throne_wishlist/public/debug-modal.js
Normal file
@ -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);
|
||||
})();
|
||||
227
plugins/throne_wishlist/views/admin.ejs
Normal file
227
plugins/throne_wishlist/views/admin.ejs
Normal file
@ -0,0 +1,227 @@
|
||||
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||
<link rel="stylesheet" href="/plugins/throne_wishlist/assets/admin.css?v=<%= assetVersion %>" />
|
||||
|
||||
<% 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])); %>
|
||||
|
||||
<header class="throne-header">
|
||||
<div>
|
||||
<h1>Throne Wishlist</h1>
|
||||
<p>Verified wishlist events and cross-platform notifications.</p>
|
||||
</div>
|
||||
<button type="button" class="button subtle" data-debug-open>Debug</button>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Diagnostics</h2>
|
||||
<p class="hint">Core availability and public delivery readiness.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="diagnostic-grid">
|
||||
<div><span>Webhook framework</span><strong><%= webhookAvailable ? "Available" : "Unavailable" %></strong></div>
|
||||
<div><span>Public base URL</span><strong><%= publicBaseUrl %></strong></div>
|
||||
<% platformStatus.forEach((platform) => { %>
|
||||
<div><span><%= platform.label %></span><strong><%= platform.sendAvailable ? "Ready" : "Unavailable" %></strong><small><%= platform.diagnostic %></small></div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% if (publicBaseUrlWarning) { %>
|
||||
<div class="callout"><%= publicBaseUrlWarning %></div>
|
||||
<% } %>
|
||||
<% if (!webhookAvailable) { %>
|
||||
<div class="callout">Install and restart with the core webhook framework patch before creating endpoints.</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Endpoints</h2>
|
||||
<p class="hint">Create multiple subscriber URLs and paste them into Throne.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/plugins/throne_wishlist/endpoints/create" class="inline-form">
|
||||
<div class="field">
|
||||
<label for="throne-identifier">Identifier</label>
|
||||
<input id="throne-identifier" name="identifier" required placeholder="cozy-carnage" />
|
||||
</div>
|
||||
<button type="submit" class="button" <%= webhookAvailable ? "" : "disabled" %>>Create endpoint</button>
|
||||
</form>
|
||||
|
||||
<% if (!endpoints.length) { %>
|
||||
<p>No Throne endpoints configured.</p>
|
||||
<% } else { %>
|
||||
<div class="table-wrap">
|
||||
<table class="table throne-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Identifier</th>
|
||||
<th>UUID</th>
|
||||
<th>Link</th>
|
||||
<th>Last payload</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% endpoints.forEach((endpoint) => { %>
|
||||
<tr>
|
||||
<td><strong><%= endpoint.identifier %></strong></td>
|
||||
<td><code><%= endpoint.uuid %></code></td>
|
||||
<td>
|
||||
<div class="copy-row">
|
||||
<input value="<%= endpoint.publicUrl %>" readonly data-copy-source="<%= endpoint.id %>" aria-label="Webhook URL for <%= endpoint.identifier %>" />
|
||||
<button type="button" class="icon-button" data-copy-button="<%= endpoint.id %>" title="Copy webhook URL" aria-label="Copy webhook URL">
|
||||
<span aria-hidden="true">⧉</span>
|
||||
</button>
|
||||
</div>
|
||||
<small class="copy-status" data-copy-status="<%= endpoint.id %>" aria-live="polite"></small>
|
||||
</td>
|
||||
<td>
|
||||
<% if (endpoint.last_payload_preview) { %>
|
||||
<% let preview = {}; try { preview = JSON.parse(endpoint.last_payload_preview); } catch {} %>
|
||||
<strong><%= preview.event_type || "unknown" %></strong>
|
||||
<small><%= preview.event_id || "No event id" %></small>
|
||||
<small><%= preview.authentic ? "Authentic" : "Rejected" %> · <%= formatTimestamp(endpoint.last_payload_at) %></small>
|
||||
<% } else { %>
|
||||
<span>Never</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-row">
|
||||
<button type="button" class="button subtle" data-confirm-open data-confirm-title="Renew endpoint?" data-confirm-text="The current Throne URL will stop working immediately." data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/renew">Renew</button>
|
||||
<button type="button" class="button subtle danger" data-confirm-open data-confirm-title="Remove endpoint?" data-confirm-text="This endpoint will stop accepting Throne payloads." data-confirm-action="/plugins/throne_wishlist/endpoints/<%= endpoint.id %>/remove">Remove</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Platform Destinations</h2>
|
||||
<p class="hint">Only destinations with a working Lumi send capability can be enabled.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/plugins/throne_wishlist/destinations" class="destination-grid">
|
||||
<% const availablePlatforms = platformStatus.filter((platform) => platform.sendAvailable); %>
|
||||
<% if (!availablePlatforms.length) { %>
|
||||
<p>No active platform sender is currently available.</p>
|
||||
<% } %>
|
||||
<% availablePlatforms.forEach((platform) => { const destination = destinationMap.get(platform.id); %>
|
||||
<fieldset class="destination-panel">
|
||||
<legend><%= platform.label %></legend>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="enabled_platforms" value="<%= platform.id %>" <%= destination?.enabled ? "checked" : "" %> />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text"><%= destination?.enabled ? "Enabled" : "Disabled" %></span>
|
||||
</label>
|
||||
<% if (platform.id === "discord") { %>
|
||||
<div class="field">
|
||||
<label for="discord-destination">Text channel</label>
|
||||
<select id="discord-destination" name="discord_destination_id">
|
||||
<option value="">Select a regular text channel</option>
|
||||
<% discordChannels.forEach((channel) => { %>
|
||||
<option value="<%= channel.id %>" <%= destination?.destination_id === channel.id ? "selected" : "" %>><%= channel.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<% } %>
|
||||
<small><%= platform.diagnostic %></small>
|
||||
</fieldset>
|
||||
<% }) %>
|
||||
<div class="field full">
|
||||
<button type="submit" class="button">Save destinations</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Event Messages</h2>
|
||||
<p class="hint">One message per event and platform. Unknown placeholders remain unchanged.</p>
|
||||
</div>
|
||||
</div>
|
||||
<details class="placeholder-list">
|
||||
<summary>Available placeholders</summary>
|
||||
<div>
|
||||
<% placeholders.forEach((placeholder) => { %><code>{<%= placeholder %>}</code> <% }) %>
|
||||
</div>
|
||||
</details>
|
||||
<div class="event-sections">
|
||||
<% eventTypes.forEach((eventType) => { %>
|
||||
<section class="event-section">
|
||||
<h3><%= eventType %></h3>
|
||||
<div class="template-grid">
|
||||
<% if (!activePlatforms.length) { %>
|
||||
<p>No active platform templates are available.</p>
|
||||
<% } %>
|
||||
<% activePlatforms.forEach((platform) => { const template = templateMap.get(eventType + ":" + platform); const status = statusMap.get(platform); %>
|
||||
<form method="post" action="/plugins/throne_wishlist/templates" class="template-panel">
|
||||
<input type="hidden" name="event_type" value="<%= eventType %>" />
|
||||
<input type="hidden" name="platform" value="<%= platform %>" />
|
||||
<div class="template-heading">
|
||||
<strong><%= status?.label || platform %></strong>
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="switch-input" name="enabled" <%= template?.enabled ? "checked" : "" %> />
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span class="switch-text"><%= template?.enabled ? "On" : "Off" %></span>
|
||||
</label>
|
||||
</div>
|
||||
<label for="template-<%= eventType %>-<%= platform %>">Message</label>
|
||||
<textarea id="template-<%= eventType %>-<%= platform %>" name="template" rows="4"><%= template?.template || "" %></textarea>
|
||||
<button type="submit" class="button subtle">Save <%= status?.label || platform %></button>
|
||||
</form>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
<% }) %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal-backdrop" data-confirm-modal aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||||
<div class="modal-header">
|
||||
<h3 id="confirm-title" data-confirm-heading>Confirm action</h3>
|
||||
<button type="button" class="icon-button" data-confirm-close aria-label="Close confirmation">×</button>
|
||||
</div>
|
||||
<p data-confirm-description></p>
|
||||
<form method="post" data-confirm-form>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="button subtle" data-confirm-close>Cancel</button>
|
||||
<button type="submit" class="button danger" data-confirm-submit disabled>Confirm in 3</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" data-debug-modal aria-hidden="true">
|
||||
<div class="modal debug-modal" role="dialog" aria-modal="true" aria-labelledby="debug-title">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 id="debug-title">Live Throne Payloads</h3>
|
||||
<p class="hint" data-debug-status>Starting debug session…</p>
|
||||
</div>
|
||||
<button type="button" class="icon-button" data-debug-close aria-label="Close debug viewer">×</button>
|
||||
</div>
|
||||
<div class="debug-toolbar">
|
||||
<button type="button" class="icon-button" data-debug-prev aria-label="Previous payload">←</button>
|
||||
<span data-debug-page>0 / 0</span>
|
||||
<input type="range" min="1" max="1" value="1" data-debug-slider aria-label="Payload page" />
|
||||
<button type="button" class="icon-button" data-debug-next aria-label="Next payload">→</button>
|
||||
</div>
|
||||
<div class="debug-summary" data-debug-summary>No payloads received during this session.</div>
|
||||
<pre class="debug-json" data-debug-json>{}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/plugins/throne_wishlist/assets/admin.js?v=<%= assetVersion %>"></script>
|
||||
<script src="/plugins/throne_wishlist/assets/debug-modal.js?v=<%= assetVersion %>"></script>
|
||||
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||
93
scripts/verify-webhooks.js
Normal file
93
scripts/verify-webhooks.js
Normal file
@ -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;
|
||||
});
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
376
src/services/webhooks.js
Normal file
376
src/services/webhooks.js
Normal file
@ -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
|
||||
};
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
BIN
updates/lumi-core-webhook-framework-patch-v0.1.0.zip
Normal file
BIN
updates/lumi-core-webhook-framework-patch-v0.1.0.zip
Normal file
Binary file not shown.
BIN
updates/lumi-plugin-throne_wishlist-v0.1.0.zip
Normal file
BIN
updates/lumi-plugin-throne_wishlist-v0.1.0.zip
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user