1025 lines
34 KiB
JavaScript
1025 lines
34 KiB
JavaScript
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;
|