From b4bae1bfc8052214d1b60e0df8a3b476fc9a939a Mon Sep 17 00:00:00 2001 From: ChrisChrome Date: Fri, 3 Jul 2026 11:08:17 -0600 Subject: [PATCH] Discord webhooks --- _test_server_temp.js | 73 -------------- lib/webhooks.js | 145 +++++++++++++++++++++++++++ migrations/0009_discord_webhooks.sql | 9 ++ public/css/style.css | 18 ++++ public/dashboard/index.html | 35 +++++++ public/js/dashboard.js | 75 ++++++++++++++ routes/api/v1/ingame.js | 12 +++ routes/dashboard.js | 63 ++++++++++++ 8 files changed, 357 insertions(+), 73 deletions(-) delete mode 100644 _test_server_temp.js create mode 100644 lib/webhooks.js create mode 100644 migrations/0009_discord_webhooks.sql diff --git a/_test_server_temp.js b/_test_server_temp.js deleted file mode 100644 index 579a8b0..0000000 --- a/_test_server_temp.js +++ /dev/null @@ -1,73 +0,0 @@ -// Temporary test harness - deleted after verification. Points at a scratch copy of the DB so the -// real database.db is never touched. -require('dotenv').config({ quiet: true }); -const express = require('express'); -const sqlite3 = require('sqlite3').verbose(); -const app = express(); -const session = require('express-session'); -const port = 3999; - -const db = new sqlite3.Database('/tmp/database_full_test.db', (err) => { - if (err) { - console.error('Failed to connect to the database:', err.message); - process.exit(1); - } else { - console.log('Connected to the SQLite database.'); - require('./migrations')(db).then(() => { - app.listen(port, () => { - console.log(`NOTXCS API is running on port ${port}`); - }); - }).catch(err => { - console.error('Failed to run migrations:', err.message); - process.exit(1); - }); - }; -}); - -app.use(express.static('public')); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(session({ - secret: 'test-secret', - resave: false, - saveUninitialized: true, - cookie: { secure: false } -})); - -app.use((req, res, next) => { - console.log(`${req.method} ${req.originalUrl}`); - next(); -}); - -const fs = require('fs'); -const path = require('path'); - -const routesPath = path.join(__dirname, 'routes'); - -function loadRoutes(dir, basePath = '/') { - const files = fs.readdirSync(dir); - - files.forEach((file) => { - const fullPath = path.join(dir, file); - const stat = fs.statSync(fullPath); - - if (stat.isDirectory()) { - loadRoutes(fullPath, path.join(basePath, file)); - } else if (file.endsWith('.js')) { - const routeName = path.basename(file, '.js'); - const routeUrl = path.join(basePath, routeName === 'index' ? '' : routeName).replace(/\\/g, '/'); - - try { - const route = require(fullPath)(db); - if (typeof route === 'function' || route.name === 'router') { - app.use(routeUrl, route); - console.log(`Loaded route: ${routeUrl}`); - } - } catch (err) { - console.error(`Error loading route ${fullPath}:`, err); - } - } - }); -} - -loadRoutes(routesPath); diff --git a/lib/webhooks.js b/lib/webhooks.js new file mode 100644 index 0000000..6c6913e --- /dev/null +++ b/lib/webhooks.js @@ -0,0 +1,145 @@ +// Sends Discord webhook notifications for place-level events (access granted/denied, reader +// arm/disarm, reader enable/disable). Uses the runtime's built-in fetch() - no extra dependency. +// +// Each place has its own webhook config (see migrations/0009_discord_webhooks.sql): +// webhookUrl - the Discord webhook URL to POST to. If falsy, notifications are skipped. +// webhookContent - optional plain-text `content` sent alongside the embed (e.g. "@here" or +// a role/user mention). Purely cosmetic - never affects delivery logic. +// webhookAccessGranted - toggle for access_granted events +// webhookAccessDenied - toggle for access_denied events +// webhookReaderArm - toggle for both reader_armed and reader_disarmed events +// webhookReaderEnable - toggle for both reader_enabled and reader_disabled events +// +// Sending is entirely fire-and-forget: failures are logged but never thrown, so a broken/rate-limited +// webhook can never affect scan results or the dashboard's own API responses. + +// Discord embed side colors (decimal), matching the dashboard's --success/--danger/--accent palette. +const COLORS = { + GREEN: 0x3ecf8e, + RED: 0xe5484d, + BLUE: 0x4f7cff, + ORANGE: 0xf5a623 +}; + +// Maps each event type to the place column that gates it, plus how to render its embed. +const EVENT_CONFIG = { + access_granted: { + toggleColumn: 'webhookAccessGranted', + title: '✅ Access granted', + color: COLORS.GREEN + }, + access_denied: { + toggleColumn: 'webhookAccessDenied', + title: '⛔ Access denied', + color: COLORS.RED + }, + reader_armed: { + toggleColumn: 'webhookReaderArm', + title: '🔒 Reader armed', + color: COLORS.BLUE + }, + reader_disarmed: { + toggleColumn: 'webhookReaderArm', + title: '🔓 Reader disarmed', + color: COLORS.ORANGE + }, + reader_enabled: { + toggleColumn: 'webhookReaderEnable', + title: '🟢 Reader enabled', + color: COLORS.GREEN + }, + reader_disabled: { + toggleColumn: 'webhookReaderEnable', + title: '🔴 Reader disabled', + color: COLORS.RED + } +}; + +// Builds the Discord embed `fields` array from a plain object, skipping any nullish values. +// Values are stringified and truncated to Discord's 1024-char field value limit. +function buildFields(data) { + return Object.entries(data) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([name, value]) => ({ + name, + value: String(value).slice(0, 1024), + inline: true + })); +} + +// POSTs a raw payload to a Discord webhook URL using the runtime's built-in fetch(). Returns +// `{ ok: true }` on a 2xx response, or `{ ok: false, message }` otherwise. Never throws. +async function postToDiscord(webhookUrl, payload) { + try { + const res = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + return { ok: false, message: `Discord responded with ${res.status}${body ? `: ${body}` : ''}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, message: err.message }; + } +} + +// Fires a Discord webhook for `eventType` on behalf of `place`, if it has a webhook URL configured +// and has that event type's toggle enabled. `fields` is a plain object of embed field name -> value +// (e.g. { Reader: 'Front Door', User: '123456' }). Never throws - logs and resolves on any failure. +async function sendPlaceWebhook(place, eventType, fields = {}) { + const config = EVENT_CONFIG[eventType]; + if (!config) { + console.error(`Unknown webhook event type: ${eventType}`); + return; + } + + if (!place || !place.webhookUrl || !place[config.toggleColumn]) { + return; // No webhook configured, or this event type isn't enabled for this place. + } + + const embed = { + title: config.title, + color: config.color, + fields: buildFields(fields), + footer: { text: place.name || place.id }, + timestamp: new Date().toISOString() + }; + + const payload = { embeds: [embed] }; + if (place.webhookContent) { + payload.content = String(place.webhookContent); + } + + const result = await postToDiscord(place.webhookUrl, payload); + if (!result.ok) { + console.error(`Discord webhook for place ${place.id} failed: ${result.message}`); + } +} + +// Sends a one-off test embed straight to `webhookUrl` (bypassing the place's toggles, since the +// user is explicitly asking to test it), optionally including `content`. Used by the dashboard's +// "Send test" button. Returns `{ ok, message }` so the caller can surface any failure to the user. +async function sendTestWebhook(webhookUrl, content) { + if (!webhookUrl) { + return { ok: false, message: 'No webhook URL configured' }; + } + + const payload = { + embeds: [{ + title: '🔔 Test notification', + description: 'This is a test alert from NOTXCS. If you can see this, your webhook is configured correctly.', + color: COLORS.BLUE, + timestamp: new Date().toISOString() + }] + }; + if (content) { + payload.content = String(content); + } + + return postToDiscord(webhookUrl, payload); +} + +module.exports = { sendPlaceWebhook, sendTestWebhook, EVENT_CONFIG }; diff --git a/migrations/0009_discord_webhooks.sql b/migrations/0009_discord_webhooks.sql new file mode 100644 index 0000000..6be49de --- /dev/null +++ b/migrations/0009_discord_webhooks.sql @@ -0,0 +1,9 @@ +-- Per-place Discord webhook configuration: a webhook URL, optional custom message content (e.g. +-- to ping a role/user), and one on/off toggle per notification type so a place can opt into just +-- the events it cares about. All toggles default to off until explicitly enabled by the user. +ALTER TABLE places ADD COLUMN webhookUrl TEXT; +ALTER TABLE places ADD COLUMN webhookContent TEXT; +ALTER TABLE places ADD COLUMN webhookAccessGranted INTEGER NOT NULL DEFAULT 0; +ALTER TABLE places ADD COLUMN webhookAccessDenied INTEGER NOT NULL DEFAULT 0; +ALTER TABLE places ADD COLUMN webhookReaderArm INTEGER NOT NULL DEFAULT 0; +ALTER TABLE places ADD COLUMN webhookReaderEnable INTEGER NOT NULL DEFAULT 0; diff --git a/public/css/style.css b/public/css/style.css index 28c9e2a..a869c86 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -416,6 +416,24 @@ button.danger { white-space: nowrap; } +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.checkbox-row input[type="checkbox"] { + width: auto; + margin: 0; +} + +.checkbox-row label { + margin: 0; + color: var(--text); + font-size: 14px; +} + .hidden { display: none !important; } diff --git a/public/dashboard/index.html b/public/dashboard/index.html index 329499c..f794296 100644 --- a/public/dashboard/index.html +++ b/public/dashboard/index.html @@ -100,6 +100,41 @@ +
+

Discord webhook

+

Post an alert to a Discord channel when readers at this place grant/deny access or get armed/disarmed/enabled/disabled. The alert itself is sent as an embed; the optional message below is sent as plain content alongside it (e.g. to ping a role or user).

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+

Readers

diff --git a/public/js/dashboard.js b/public/js/dashboard.js index f9fcc73..d01a2c5 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -367,6 +367,8 @@ async function selectPlace(placeId) { document.getElementById('toggle-api-key-btn').textContent = 'Show'; document.getElementById('place-api-key-custom').value = ''; + populateWebhookForm(data.place); + currentPlaceCanEdit = currentOrgRole >= 2; const canEdit = currentPlaceCanEdit; document.getElementById('delete-place-btn').classList.toggle('hidden', !canEdit); @@ -375,6 +377,7 @@ async function selectPlace(placeId) { document.getElementById('regenerate-api-key-btn').disabled = !canEdit; document.getElementById('add-reader-form').querySelector('button').disabled = !canEdit; document.getElementById('add-access-group-form').querySelector('button').disabled = !canEdit; + setWebhookFormEditable(canEdit); if (canEdit) { populateMoveOrgSelect(); @@ -711,6 +714,78 @@ document.getElementById('set-api-key-form').addEventListener('submit', async (e) } }); +// --- Discord webhook settings --- + +function populateWebhookForm(place) { + document.getElementById('webhook-url').value = place.webhookUrl || ''; + document.getElementById('webhook-content').value = place.webhookContent || ''; + document.getElementById('webhook-access-granted').checked = !!place.webhookAccessGranted; + document.getElementById('webhook-access-denied').checked = !!place.webhookAccessDenied; + document.getElementById('webhook-reader-arm').checked = !!place.webhookReaderArm; + document.getElementById('webhook-reader-enable').checked = !!place.webhookReaderEnable; +} + +function setWebhookFormEditable(canEdit) { + const form = document.getElementById('webhook-form'); + form.querySelectorAll('input').forEach((input) => { input.disabled = !canEdit; }); + form.querySelector('button[type="submit"]').disabled = !canEdit; + document.getElementById('webhook-test-btn').disabled = !canEdit; +} + +document.getElementById('webhook-form').addEventListener('submit', async (e) => { + e.preventDefault(); + if (!currentPlaceId) return; + + const body = { + webhookUrl: document.getElementById('webhook-url').value.trim(), + webhookContent: document.getElementById('webhook-content').value.trim(), + webhookAccessGranted: document.getElementById('webhook-access-granted').checked, + webhookAccessDenied: document.getElementById('webhook-access-denied').checked, + webhookReaderArm: document.getElementById('webhook-reader-arm').checked, + webhookReaderEnable: document.getElementById('webhook-reader-enable').checked + }; + + const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/webhook`, { + method: 'PUT', + body: JSON.stringify(body) + }); + + if (result.success) { + await loadPlaces(); + } else { + alert(result.message || 'Failed to save webhook settings'); + } +}); + +document.getElementById('webhook-test-btn').addEventListener('click', async () => { + if (!currentPlaceId) return; + + const btn = document.getElementById('webhook-test-btn'); + const originalLabel = btn.textContent; + btn.textContent = 'Sending...'; + btn.disabled = true; + + try { + const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/webhook/test`, { + method: 'POST', + body: JSON.stringify({ + webhookUrl: document.getElementById('webhook-url').value.trim(), + webhookContent: document.getElementById('webhook-content').value.trim() + }) + }); + + if (result.success) { + btn.textContent = 'Sent!'; + } else { + alert(result.message || 'Failed to send test webhook'); + btn.textContent = originalLabel; + } + } finally { + btn.disabled = !currentPlaceCanEdit; + setTimeout(() => { btn.textContent = originalLabel; }, 1500); + } +}); + document.getElementById('logout-btn').addEventListener('click', async () => { await api('/auth/logout', { method: 'POST' }); window.location.href = '/login'; diff --git a/routes/api/v1/ingame.js b/routes/api/v1/ingame.js index ea93745..f6824ea 100644 --- a/routes/api/v1/ingame.js +++ b/routes/api/v1/ingame.js @@ -1,6 +1,7 @@ let db; const express = require('express'); const router = express.Router(); +const { sendPlaceWebhook } = require('../../../lib/webhooks'); module.exports = (dbInit) => { db = dbInit; @@ -139,6 +140,12 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => { if (err) console.error('Failed to record scan log:', err.message); } ); + sendPlaceWebhook(place, 'access_denied', { + Reader: ap.name, + 'User ID': userId || 'unknown', + 'Card(s)': cardNumbers.join(', ') || undefined, + Reason: globalLockdownActive ? 'Site-wide lockdown active' : 'Place lockdown active' + }); return res.json({ success: true, grant_type: 'user_scan', @@ -225,6 +232,11 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => { if (err) console.error('Failed to record scan log:', err.message); } ); + sendPlaceWebhook(place, responseCode, { + Reader: ap.name, + 'User ID': userId || 'unknown', + 'Card(s)': cardNumbers.join(', ') || undefined + }); const responseData = granted ? { success: true, diff --git a/routes/dashboard.js b/routes/dashboard.js index f5d48f8..20362f3 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -5,6 +5,7 @@ const path = require('path'); const { requireAuth, ROLES, ORG_ROLES } = require('../lib/auth'); const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators'); const { getEffectiveOrgRole } = require('../lib/orgs'); +const { sendPlaceWebhook, sendTestWebhook } = require('../lib/webhooks'); const KIT_TEMPLATE_PATH = path.join(__dirname, '..', 'xcs-template.rbxmx'); @@ -474,6 +475,52 @@ router.put('/places/:placeId', loadPlace, requireOrgEdit, (req, res) => { }); }); +// Update a place's Discord webhook configuration: the webhook URL, an optional custom message +// `content` (e.g. to ping a role/user), and which event types to notify for. Requires Edit access +// in the place's organization, same as other place settings. +router.put('/places/:placeId/webhook', loadPlace, requireOrgEdit, (req, res) => { + const body = req.body || {}; + + const webhookUrl = body.webhookUrl !== undefined ? (String(body.webhookUrl).trim() || null) : req.place.webhookUrl; + if (webhookUrl && !/^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\//.test(webhookUrl)) { + return res.status(400).json({ success: false, message: "That doesn't look like a valid Discord webhook URL" }); + } + + const webhookContent = body.webhookContent !== undefined ? (String(body.webhookContent).trim() || null) : req.place.webhookContent; + const webhookAccessGranted = body.webhookAccessGranted !== undefined ? (body.webhookAccessGranted ? 1 : 0) : req.place.webhookAccessGranted; + const webhookAccessDenied = body.webhookAccessDenied !== undefined ? (body.webhookAccessDenied ? 1 : 0) : req.place.webhookAccessDenied; + const webhookReaderArm = body.webhookReaderArm !== undefined ? (body.webhookReaderArm ? 1 : 0) : req.place.webhookReaderArm; + const webhookReaderEnable = body.webhookReaderEnable !== undefined ? (body.webhookReaderEnable ? 1 : 0) : req.place.webhookReaderEnable; + + db.run( + `UPDATE places SET webhookUrl = ?, webhookContent = ?, webhookAccessGranted = ?, webhookAccessDenied = ?, webhookReaderArm = ?, webhookReaderEnable = ? WHERE id = ?`, + [webhookUrl, webhookContent, webhookAccessGranted, webhookAccessDenied, webhookReaderArm, webhookReaderEnable, req.place.id], + (err) => { + if (err) { + console.error('Failed to update place webhook settings:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ + success: true, + webhook: { webhookUrl, webhookContent, webhookAccessGranted, webhookAccessDenied, webhookReaderArm, webhookReaderEnable } + }); + } + ); +}); + +// Send a one-off test embed to the place's currently-configured (or just-entered, via body.webhookUrl) +// Discord webhook, so the user can confirm it's set up correctly before relying on it. +router.post('/places/:placeId/webhook/test', loadPlace, requireOrgEdit, async (req, res) => { + const webhookUrl = (req.body && req.body.webhookUrl !== undefined ? req.body.webhookUrl : req.place.webhookUrl); + const content = (req.body && req.body.webhookContent !== undefined ? req.body.webhookContent : req.place.webhookContent); + + const result = await sendTestWebhook(webhookUrl, content); + if (!result.ok) { + return res.status(400).json({ success: false, message: result.message || 'Failed to send test webhook' }); + } + res.json({ success: true }); +}); + // Move a place to a different organization. Requires Edit access in the place's current // organization, and at least Edit access in the destination organization. router.post('/places/:placeId/move', loadPlace, requireOrgEdit, (req, res) => { @@ -707,6 +754,22 @@ router.put('/places/:placeId/access-points/:accessPointId', loadPlace, requireOr console.error('Failed to update reader:', err.message); return res.status(500).json({ success: false, message: "Internal server error" }); } + + // Fire webhooks for any state actually toggled by this request, named after who did it. + const actor = req.user ? req.user.username : 'someone'; + if (!!enabled !== !!ap.enabled) { + sendPlaceWebhook(req.place, enabled ? 'reader_enabled' : 'reader_disabled', { + Reader: name, + By: actor + }); + } + if (!!armState !== !!ap.armState) { + sendPlaceWebhook(req.place, armState ? 'reader_armed' : 'reader_disarmed', { + Reader: name, + By: actor + }); + } + res.json({ success: true }); } );