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 @@ +
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).
+ +