From 5bcda717418afdc840a0d709a4add8d8cfe415aa Mon Sep 17 00:00:00 2001 From: ChrisChrome Date: Thu, 2 Jul 2026 11:23:28 -0600 Subject: [PATCH] Feat: Lockdown system; Fix: Generated API Keys borked --- lib/generators.js | 2 +- migrations/0006_lockdown.sql | 11 ++ migrations/0007_place_lockdown.sql | 6 + public/admin/index.html | 10 ++ public/css/style.css | 7 + public/dashboard/index.html | 10 ++ public/js/admin.js | 45 ++++++ public/js/dashboard.js | 70 ++++++++- routes/admin.js | 51 ++++++ routes/api/v1/ingame.js | 240 +++++++++++++++++------------ routes/dashboard.js | 44 ++++++ 11 files changed, 395 insertions(+), 101 deletions(-) create mode 100644 migrations/0006_lockdown.sql create mode 100644 migrations/0007_place_lockdown.sql diff --git a/lib/generators.js b/lib/generators.js index d633df4..2ecd751 100644 --- a/lib/generators.js +++ b/lib/generators.js @@ -1,7 +1,7 @@ const crypto = require('crypto'); // Characters used when generating a random API key: letters, digits and a handful of symbols. -const API_KEY_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+'; +const API_KEY_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const API_KEY_LENGTH = 64; // Characters used when generating a random reader id: letters and digits only. diff --git a/migrations/0006_lockdown.sql b/migrations/0006_lockdown.sql new file mode 100644 index 0000000..4149649 --- /dev/null +++ b/migrations/0006_lockdown.sql @@ -0,0 +1,11 @@ +-- Single-row table holding the global lockdown state. When active, every reader at every place +-- is disabled and all scans are denied, regardless of that reader's own enabled/armState/ACL config. +CREATE TABLE IF NOT EXISTS system_lockdown ( + id INTEGER PRIMARY KEY CHECK (id = 1), + active INTEGER NOT NULL DEFAULT 0, + activatedBy INTEGER, + activatedAt DATETIME, + FOREIGN KEY(activatedBy) REFERENCES users(id) +); + +INSERT OR IGNORE INTO system_lockdown (id, active) VALUES (1, 0); diff --git a/migrations/0007_place_lockdown.sql b/migrations/0007_place_lockdown.sql new file mode 100644 index 0000000..bd0f789 --- /dev/null +++ b/migrations/0007_place_lockdown.sql @@ -0,0 +1,6 @@ +-- Per-place lockdown, separate from the global (all-places) lockdown in system_lockdown. Unlike the +-- global lockdown, this can be engaged/lifted by anyone with access to the place (owner, shared +-- access, or elevated/admin bypass) - see routes/dashboard.js. +ALTER TABLE places ADD COLUMN lockdown INTEGER NOT NULL DEFAULT 0; +ALTER TABLE places ADD COLUMN lockdownActivatedBy INTEGER REFERENCES users(id); +ALTER TABLE places ADD COLUMN lockdownActivatedAt DATETIME; diff --git a/public/admin/index.html b/public/admin/index.html index 1cc13b8..c89ea4d 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -20,6 +20,16 @@

User administration

+
+

Site-wide lockdown

+

While engaged, every reader at every place is disabled and all scans are denied, regardless of that reader's own settings, until the lockdown is lifted.

+
+ Loading… + + +
+
+

Create user

diff --git a/public/css/style.css b/public/css/style.css index 5e532c6..97b7882 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -194,6 +194,13 @@ button.danger { gap: 2px; } +.place-list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + .place-list-id { font-size: 14px; } diff --git a/public/dashboard/index.html b/public/dashboard/index.html index 98fd7ab..f80e86c 100644 --- a/public/dashboard/index.html +++ b/public/dashboard/index.html @@ -33,6 +33,10 @@
+ +
Select or create a place from the sidebar to manage its readers.
@@ -41,9 +45,15 @@

+ +
+ +

Name

diff --git a/public/js/admin.js b/public/js/admin.js index 861f20d..d0aeb21 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -47,8 +47,53 @@ async function init() { } await loadUsers(); + await loadLockdownStatus(); } +async function loadLockdownStatus() { + const data = await api('/admin/lockdown'); + const badge = document.getElementById('lockdown-badge'); + const engageBtn = document.getElementById('engage-lockdown-btn'); + const liftBtn = document.getElementById('lift-lockdown-btn'); + + if (!data.success) { + badge.textContent = 'Unknown'; + return; + } + + if (data.active) { + badge.textContent = 'Lockdown active'; + badge.className = 'badge off'; + engageBtn.classList.add('hidden'); + liftBtn.classList.remove('hidden'); + } else { + badge.textContent = 'Normal operation'; + badge.className = 'badge on'; + engageBtn.classList.remove('hidden'); + liftBtn.classList.add('hidden'); + } +} + +document.getElementById('engage-lockdown-btn').addEventListener('click', async () => { + if (!confirm('Engage site-wide lockdown? This will disable every reader at every place until lifted.')) return; + + const result = await api('/admin/lockdown', { method: 'POST' }); + if (result.success) { + await loadLockdownStatus(); + } else { + alert(result.message || 'Failed to engage lockdown'); + } +}); + +document.getElementById('lift-lockdown-btn').addEventListener('click', async () => { + const result = await api('/admin/lockdown', { method: 'DELETE' }); + if (result.success) { + await loadLockdownStatus(); + } else { + alert(result.message || 'Failed to lift lockdown'); + } +}); + async function loadUsers() { const data = await api('/admin/users'); users = data.users || []; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index d1075c0..898507d 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -37,9 +37,18 @@ async function init() { document.getElementById('add-place-custom-fields').classList.toggle('hidden', me.user.role < 3); document.getElementById('set-api-key-form').classList.toggle('hidden', me.user.role < 3); + await loadLockdownBanner(); await loadPlaces(); } +async function loadLockdownBanner() { + const data = await api('/dashboard/lockdown'); + const banner = document.getElementById('lockdown-banner'); + if (banner) { + banner.classList.toggle('hidden', !(data.success && data.active)); + } +} + async function loadPlaces() { const data = await api('/dashboard/places'); places = data.places || []; @@ -54,10 +63,22 @@ function renderPlaceList() { const li = document.createElement('li'); li.className = place.id === currentPlaceId ? 'active' : ''; + const row = document.createElement('div'); + row.className = 'place-list-row'; + const idSpan = document.createElement('span'); idSpan.className = 'place-list-id'; idSpan.textContent = place.name || place.id; - li.appendChild(idSpan); + row.appendChild(idSpan); + + if (place.lockdown) { + const lockdownBadge = document.createElement('span'); + lockdownBadge.className = 'badge off'; + lockdownBadge.textContent = 'Locked down'; + row.appendChild(lockdownBadge); + } + + li.appendChild(row); if (place.ownerUsername) { const ownerSpan = document.createElement('span'); @@ -97,6 +118,8 @@ async function selectPlace(placeId) { document.getElementById('delete-place-btn').classList.toggle('hidden', !canManageOwnership); document.getElementById('shared-access-card').classList.toggle('hidden', !canManageOwnership); + updatePlaceLockdownUI(!!data.place.lockdown); + renderReaders(data.accessPoints || []); await loadAccessGroups(); if (canManageOwnership) { @@ -104,6 +127,28 @@ async function selectPlace(placeId) { } } +function updatePlaceLockdownUI(active) { + const engageBtn = document.getElementById('engage-place-lockdown-btn'); + const liftBtn = document.getElementById('lift-place-lockdown-btn'); + const banner = document.getElementById('place-lockdown-banner'); + + if (active) { + engageBtn.classList.add('hidden'); + liftBtn.classList.remove('hidden'); + banner.classList.remove('hidden'); + } else { + engageBtn.classList.remove('hidden'); + liftBtn.classList.add('hidden'); + banner.classList.add('hidden'); + } + + const place = places.find((p) => p.id === currentPlaceId); + if (place) { + place.lockdown = active ? 1 : 0; + renderPlaceList(); + } +} + function renderReaders(accessPoints) { const grid = document.getElementById('readers-grid'); const empty = document.getElementById('readers-empty'); @@ -234,6 +279,29 @@ document.getElementById('delete-place-btn').addEventListener('click', async () = await loadPlaces(); }); +document.getElementById('engage-place-lockdown-btn').addEventListener('click', async () => { + if (!currentPlaceId) return; + if (!confirm('Engage lockdown for this place? This disables every reader here until lifted.')) return; + + const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/lockdown`, { method: 'POST' }); + if (result.success) { + updatePlaceLockdownUI(true); + } else { + alert(result.message || 'Failed to engage lockdown'); + } +}); + +document.getElementById('lift-place-lockdown-btn').addEventListener('click', async () => { + if (!currentPlaceId) return; + + const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/lockdown`, { method: 'DELETE' }); + if (result.success) { + updatePlaceLockdownUI(false); + } else { + alert(result.message || 'Failed to lift lockdown'); + } +}); + document.getElementById('download-kit-btn').addEventListener('click', async () => { if (!currentPlaceId) return; diff --git a/routes/admin.js b/routes/admin.js index 9fbe5be..bcc0514 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -195,3 +195,54 @@ router.delete('/users/:userId', (req, res) => { }); }); }); + +// Get the current global lockdown state. While active, every reader at every place is disabled +// and all scans are denied, regardless of that reader's own settings or ACL entries. +router.get('/lockdown', (req, res) => { + db.get( + `SELECT active, activatedBy, activatedAt FROM system_lockdown WHERE id = 1`, + [], + (err, row) => { + if (err) { + console.error('Failed to retrieve lockdown state:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ + success: true, + active: !!(row && row.active), + activatedBy: row ? row.activatedBy : null, + activatedAt: row ? row.activatedAt : null + }); + } + ); +}); + +// Engage the lockdown for all places. Any admin (or superadmin) may do this. +router.post('/lockdown', (req, res) => { + db.run( + `UPDATE system_lockdown SET active = 1, activatedBy = ?, activatedAt = CURRENT_TIMESTAMP WHERE id = 1`, + [req.user.id], + (err) => { + if (err) { + console.error('Failed to engage lockdown:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, active: true }); + } + ); +}); + +// Lift the lockdown, restoring every reader to its own configured state. +router.delete('/lockdown', (req, res) => { + db.run( + `UPDATE system_lockdown SET active = 0, activatedBy = NULL, activatedAt = NULL WHERE id = 1`, + [], + (err) => { + if (err) { + console.error('Failed to lift lockdown:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, active: false }); + } + ); +}); diff --git a/routes/api/v1/ingame.js b/routes/api/v1/ingame.js index d1ba8c7..ea93745 100644 --- a/routes/api/v1/ingame.js +++ b/routes/api/v1/ingame.js @@ -43,29 +43,41 @@ router.get('/:placeId', (req, res) => { return res.status(500).json({ success: false, message: "Internal server error" }); } - let resp = { - success: true, - accessPoints: accessPoints.reduce((acc, ap) => { - const armed = !!ap.armState; // Will have state 2 eventually for armed on schedule, but for now just 1 or 0. + db.get(`SELECT active FROM system_lockdown WHERE id = 1`, [], (err, lockdownRow) => { + if (err) { + console.error('Failed to retrieve lockdown state:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } - acc[ap.id] = { - id: ap.id, - name: ap.name, - unlockTime: ap.unlockTime, - config: { - active: !!ap.enabled, - armed, - scanData: { - ready: JSON.parse(ap.readyData || '{}'), - disarmed: JSON.parse(ap.disarmedData || '{}') + // While a lockdown is active (either the global, all-places lockdown or this place's own + // lockdown), every reader is reported as inactive regardless of its own `enabled` setting. + const lockdownActive = !!(lockdownRow && lockdownRow.active) || !!row.lockdown; + + let resp = { + success: true, + lockdown: lockdownActive, + accessPoints: accessPoints.reduce((acc, ap) => { + const armed = !!ap.armState; // Will have state 2 eventually for armed on schedule, but for now just 1 or 0. + + acc[ap.id] = { + id: ap.id, + name: ap.name, + unlockTime: ap.unlockTime, + config: { + active: lockdownActive ? false : !!ap.enabled, + armed, + scanData: { + ready: JSON.parse(ap.readyData || '{}'), + disarmed: JSON.parse(ap.disarmedData || '{}') + } } } - } - return acc; - }, {}) - }; - console.log(resp) - res.json(resp); + return acc; + }, {}) + }; + // console.log(resp) + res.json(resp); + }); }); }); }); @@ -107,98 +119,128 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => { return res.status(404).json({ success: false, message: "Access point not found" }); } - db.all(`SELECT * FROM acl WHERE accessPoint = ?`, [accessPointId], async (err, aclEntries) => { + db.get(`SELECT active FROM system_lockdown WHERE id = 1`, [], (err, lockdownRow) => { if (err) { - console.error('Failed to retrieve ACL entries:', err.message); + console.error('Failed to retrieve lockdown state:', err.message); return res.status(500).json({ success: false, message: "Internal server error" }); } - // Access group entries (type 5) reference an access_groups.id in `data`. Resolve their - // members up front so the group's users/cards/ranks are checked just like direct entries. - const groupIds = [...new Set(aclEntries.filter(e => e.type === 5).map(e => e.data))]; - let groupMembersById = {}; - if (groupIds.length > 0) { - const placeholders = groupIds.map(() => '?').join(','); - groupMembersById = await new Promise((resolve) => { - db.all(`SELECT * FROM access_group_members WHERE groupId IN (${placeholders})`, groupIds, (err, members) => { - if (err) { - console.error('Failed to retrieve access group members:', err.message); - return resolve({}); - } - const byGroup = {}; - for (const member of members) { - // Groups can only contain direct credentials (types 0-4); the API rejects - // nested groups on write, but skip any type 5 here too as defense-in-depth - // against infinite recursion if the database is ever modified directly. - if (member.type === 5) continue; - if (!byGroup[member.groupId]) byGroup[member.groupId] = []; - byGroup[member.groupId].push(member); - } - resolve(byGroup); - }); + const globalLockdownActive = !!(lockdownRow && lockdownRow.active); + const placeLockdownActive = !!place.lockdown; + + if (globalLockdownActive || placeLockdownActive) { + // Every reader is disabled during a lockdown (either global, all-places, or just this + // place) - deny immediately without consulting the ACL, and log it distinctly so it's + // clear this wasn't a normal denial. + db.run( + `INSERT INTO scan_logs (placeId, accessPoint, userId, cardNumbers, granted, responseCode) VALUES (?, ?, ?, ?, ?, ?)`, + [placeId, accessPointId, userId || null, cardNumbers.join(','), 0, globalLockdownActive ? 'lockdown' : 'place_lockdown'], + (err) => { + if (err) console.error('Failed to record scan log:', err.message); + } + ); + return res.json({ + success: true, + grant_type: 'user_scan', + response_code: 'access_denied', + response_time: 0, + scan_data: JSON.parse(ap.deniedData || '{}') }); } - let userGroups = await fetch(`https://groups.roblox.com/v1/users/${userId}/groups/roles`).then(r => r.json()).then(data => data.data || []).catch(() => []); - userGroups = userGroups.map(g => ({group: g.group.id, rank: g.role.rank})); - console.log('User groups:', userGroups); + db.all(`SELECT * FROM acl WHERE accessPoint = ?`, [accessPointId], async (err, aclEntries) => { + if (err) { + console.error('Failed to retrieve ACL entries:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } - // Types: 0 = User ID; 1 = Card Number; 2 = Group Exact Rank; 3 = Group Min Rank; 4 = Allow All; 5 = Access Group - function matchesEntry(entry) { - switch (entry.type) { - case 0: // User ID - return entry.data == userId; - case 1: // Card Number - return cardNumbers.includes(entry.data); - case 2: { // Group Exact Rank - const [group, rank] = entry.data.split(':'); - return userGroups.some(g => g.group == group && g.rank == rank); + // Access group entries (type 5) reference an access_groups.id in `data`. Resolve their + // members up front so the group's users/cards/ranks are checked just like direct entries. + const groupIds = [...new Set(aclEntries.filter(e => e.type === 5).map(e => e.data))]; + let groupMembersById = {}; + if (groupIds.length > 0) { + const placeholders = groupIds.map(() => '?').join(','); + groupMembersById = await new Promise((resolve) => { + db.all(`SELECT * FROM access_group_members WHERE groupId IN (${placeholders})`, groupIds, (err, members) => { + if (err) { + console.error('Failed to retrieve access group members:', err.message); + return resolve({}); + } + const byGroup = {}; + for (const member of members) { + // Groups can only contain direct credentials (types 0-4); the API rejects + // nested groups on write, but skip any type 5 here too as defense-in-depth + // against infinite recursion if the database is ever modified directly. + if (member.type === 5) continue; + if (!byGroup[member.groupId]) byGroup[member.groupId] = []; + byGroup[member.groupId].push(member); + } + resolve(byGroup); + }); + }); + } + + let userGroups = await fetch(`https://groups.roblox.com/v1/users/${userId}/groups/roles`).then(r => r.json()).then(data => data.data || []).catch(() => []); + userGroups = userGroups.map(g => ({group: g.group.id, rank: g.role.rank})); + // console.log('User groups:', userGroups); + + // Types: 0 = User ID; 1 = Card Number; 2 = Group Exact Rank; 3 = Group Min Rank; 4 = Allow All; 5 = Access Group + function matchesEntry(entry) { + switch (entry.type) { + case 0: // User ID + return entry.data == userId; + case 1: // Card Number + return cardNumbers.includes(entry.data); + case 2: { // Group Exact Rank + const [group, rank] = entry.data.split(':'); + return userGroups.some(g => g.group == group && g.rank == rank); + } + case 3: { // Group Min Rank + const [group, rank] = entry.data.split(':'); + return userGroups.some(g => g.group == group && g.rank >= rank); + } + case 4: // Allow All + return true; + case 5: // Access Group - granted if any credential in the group matches. + return (groupMembersById[entry.data] || []).some(matchesEntry); + default: + return false; } - case 3: { // Group Min Rank - const [group, rank] = entry.data.split(':'); - return userGroups.some(g => g.group == group && g.rank >= rank); + } + + let granted = false; + // Check every entry (users, cards, group ranks, then access groups made up of the above). + for (const entry of aclEntries) { + if (matchesEntry(entry)) { + granted = true; + break; } - case 4: // Allow All - return true; - case 5: // Access Group - granted if any credential in the group matches. - return (groupMembersById[entry.data] || []).some(matchesEntry); - default: - return false; } - } - let granted = false; - // Check every entry (users, cards, group ranks, then access groups made up of the above). - for (const entry of aclEntries) { - if (matchesEntry(entry)) { - granted = true; - break; + const responseCode = granted ? 'access_granted' : 'access_denied'; + db.run( + `INSERT INTO scan_logs (placeId, accessPoint, userId, cardNumbers, granted, responseCode) VALUES (?, ?, ?, ?, ?, ?)`, + [placeId, accessPointId, userId || null, cardNumbers.join(','), granted ? 1 : 0, responseCode], + (err) => { + if (err) console.error('Failed to record scan log:', err.message); + } + ); + + const responseData = granted ? { + success: true, + grant_type: 'user_scan', + response_code: 'access_granted', + response_time: 0, // This is just analytics for how long their backend took to generate the response + scan_data: JSON.parse(ap.grantedData || '{}') + } : { + success: true, + grant_type: 'user_scan', + response_code: 'access_denied', + response_time: 0, + scan_data: JSON.parse(ap.deniedData || '{}') } - } - - const responseCode = granted ? 'access_granted' : 'access_denied'; - db.run( - `INSERT INTO scan_logs (placeId, accessPoint, userId, cardNumbers, granted, responseCode) VALUES (?, ?, ?, ?, ?, ?)`, - [placeId, accessPointId, userId || null, cardNumbers.join(','), granted ? 1 : 0, responseCode], - (err) => { - if (err) console.error('Failed to record scan log:', err.message); - } - ); - - const responseData = granted ? { - success: true, - grant_type: 'user_scan', - response_code: 'access_granted', - response_time: 0, // This is just analytics for how long their backend took to generate the response - scan_data: JSON.parse(ap.grantedData || '{}') - } : { - success: true, - grant_type: 'user_scan', - response_code: 'access_denied', - response_time: 0, - scan_data: JSON.parse(ap.deniedData || '{}') - } - res.json(responseData); + res.json(responseData); + }); }); }); }); diff --git a/routes/dashboard.js b/routes/dashboard.js index 1ea6bcd..3adc031 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -65,6 +65,18 @@ function requireOwnerOrBypass(req, res, next) { router.use(requireAuth(() => db)); +// Read-only lockdown status check, available to any authenticated user (not just admins) so the +// dashboard can show a banner while readers are disabled site-wide. +router.get('/lockdown', (req, res) => { + db.get(`SELECT active FROM system_lockdown WHERE id = 1`, [], (err, row) => { + if (err) { + console.error('Failed to retrieve lockdown state:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, active: !!(row && row.active) }); + }); +}); + // List places owned by, or shared with, the current user. Elevated/admin users see every place. // Includes the owner's username (as `ownerUsername`) so the sidebar can display who owns each place. router.get('/places', (req, res) => { @@ -186,6 +198,38 @@ router.post('/places/:placeId/regenerate-key', loadPlace, (req, res) => { }); }); +// Engage a lockdown for this place only, disabling every reader at this place until it's lifted. +// Anyone with access to the place (owner, shared access, or elevated/admin bypass) may do this - +// unlike the global (all-places) lockdown in routes/admin.js, it doesn't require the admin role. +router.post('/places/:placeId/lockdown', loadPlace, (req, res) => { + db.run( + `UPDATE places SET lockdown = 1, lockdownActivatedBy = ?, lockdownActivatedAt = CURRENT_TIMESTAMP WHERE id = ?`, + [req.user.id, req.place.id], + (err) => { + if (err) { + console.error('Failed to engage place lockdown:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, lockdown: true }); + } + ); +}); + +// Lift this place's lockdown, restoring every reader at this place to its own configured state. +router.delete('/places/:placeId/lockdown', loadPlace, (req, res) => { + db.run( + `UPDATE places SET lockdown = 0, lockdownActivatedBy = NULL, lockdownActivatedAt = NULL WHERE id = ?`, + [req.place.id], + (err) => { + if (err) { + console.error('Failed to lift place lockdown:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, lockdown: false }); + } + ); +}); + // Download the place's Roblox kit (xcs-template.rbxmx) with the placeId/apiKey placeholders // filled in for this specific place. Available to anyone with access to the place. router.get('/places/:placeId/kit', loadPlace, (req, res) => {