Discord webhooks

This commit is contained in:
Christopher Cookman 2026-07-03 11:08:17 -06:00
parent e24df1139b
commit b4bae1bfc8
8 changed files with 357 additions and 73 deletions

View file

@ -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);

145
lib/webhooks.js Normal file
View file

@ -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 };

View file

@ -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;

View file

@ -416,6 +416,24 @@ button.danger {
white-space: nowrap; 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 { .hidden {
display: none !important; display: none !important;
} }

View file

@ -100,6 +100,41 @@
</form> </form>
</div> </div>
<div class="card">
<h3>Discord webhook</h3>
<p style="margin-top:-8px;color:var(--muted);font-size:13px;">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).</p>
<form id="webhook-form">
<div class="field-group">
<label for="webhook-url">Webhook URL</label>
<input type="text" id="webhook-url" placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="field-group">
<label for="webhook-content">Custom message content <span style="color:var(--muted);font-weight:normal;">(optional, e.g. @here or a role mention)</span></label>
<input type="text" id="webhook-content" placeholder="@here">
</div>
<div class="checkbox-row">
<input type="checkbox" id="webhook-access-granted">
<label for="webhook-access-granted">Access granted</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="webhook-access-denied">
<label for="webhook-access-denied">Access denied</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="webhook-reader-arm">
<label for="webhook-reader-arm">Reader armed / disarmed</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="webhook-reader-enable">
<label for="webhook-reader-enable">Reader enabled / disabled</label>
</div>
<div class="inline-form" style="margin-top:12px;">
<button type="submit" class="primary">Save</button>
<button type="button" class="secondary" id="webhook-test-btn">Send test</button>
</div>
</form>
</div>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3>Readers</h3> <h3>Readers</h3>

View file

@ -367,6 +367,8 @@ async function selectPlace(placeId) {
document.getElementById('toggle-api-key-btn').textContent = 'Show'; document.getElementById('toggle-api-key-btn').textContent = 'Show';
document.getElementById('place-api-key-custom').value = ''; document.getElementById('place-api-key-custom').value = '';
populateWebhookForm(data.place);
currentPlaceCanEdit = currentOrgRole >= 2; currentPlaceCanEdit = currentOrgRole >= 2;
const canEdit = currentPlaceCanEdit; const canEdit = currentPlaceCanEdit;
document.getElementById('delete-place-btn').classList.toggle('hidden', !canEdit); 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('regenerate-api-key-btn').disabled = !canEdit;
document.getElementById('add-reader-form').querySelector('button').disabled = !canEdit; document.getElementById('add-reader-form').querySelector('button').disabled = !canEdit;
document.getElementById('add-access-group-form').querySelector('button').disabled = !canEdit; document.getElementById('add-access-group-form').querySelector('button').disabled = !canEdit;
setWebhookFormEditable(canEdit);
if (canEdit) { if (canEdit) {
populateMoveOrgSelect(); 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 () => { document.getElementById('logout-btn').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' }); await api('/auth/logout', { method: 'POST' });
window.location.href = '/login'; window.location.href = '/login';

View file

@ -1,6 +1,7 @@
let db; let db;
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { sendPlaceWebhook } = require('../../../lib/webhooks');
module.exports = (dbInit) => { module.exports = (dbInit) => {
db = 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); 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({ return res.json({
success: true, success: true,
grant_type: 'user_scan', 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); 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 ? { const responseData = granted ? {
success: true, success: true,

View file

@ -5,6 +5,7 @@ const path = require('path');
const { requireAuth, ROLES, ORG_ROLES } = require('../lib/auth'); const { requireAuth, ROLES, ORG_ROLES } = require('../lib/auth');
const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators'); const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators');
const { getEffectiveOrgRole } = require('../lib/orgs'); const { getEffectiveOrgRole } = require('../lib/orgs');
const { sendPlaceWebhook, sendTestWebhook } = require('../lib/webhooks');
const KIT_TEMPLATE_PATH = path.join(__dirname, '..', 'xcs-template.rbxmx'); 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 // 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. // organization, and at least Edit access in the destination organization.
router.post('/places/:placeId/move', loadPlace, requireOrgEdit, (req, res) => { 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); console.error('Failed to update reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" }); 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 }); res.json({ success: true });
} }
); );