Feat: Lockdown system; Fix: Generated API Keys borked

This commit is contained in:
Christopher Cookman 2026-07-02 11:23:28 -06:00
parent 179103f1cc
commit 5bcda71741
11 changed files with 395 additions and 101 deletions

View file

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

View file

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

View file

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

View file

@ -20,6 +20,16 @@
<h1>User administration</h1>
</div>
<div class="card" id="lockdown-card">
<h3>Site-wide lockdown</h3>
<p style="margin-top:-8px;color:var(--muted);font-size:13px;">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.</p>
<div class="inline-form">
<span class="badge" id="lockdown-badge">Loading&hellip;</span>
<button type="button" class="danger" id="engage-lockdown-btn">Engage lockdown</button>
<button type="button" class="secondary hidden" id="lift-lockdown-btn">Lift lockdown</button>
</div>
</div>
<div class="card">
<h3>Create user</h3>
<form id="add-user-form" class="inline-form">

View file

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

View file

@ -33,6 +33,10 @@
</aside>
<main class="main">
<div id="lockdown-banner" class="error hidden" style="display:block;margin-bottom:16px;">
Site-wide lockdown is active - every reader at every place is disabled until an admin lifts it.
</div>
<div id="no-place" class="empty-state">
Select or create a place from the sidebar to manage its readers.
</div>
@ -41,9 +45,15 @@
<div class="main-header">
<h1 id="place-title"></h1>
<button class="secondary" id="download-kit-btn">Download kit</button>
<button class="danger" id="engage-place-lockdown-btn">Engage lockdown</button>
<button class="secondary hidden" id="lift-place-lockdown-btn">Lift lockdown</button>
<button class="danger" id="delete-place-btn">Delete place</button>
</div>
<div id="place-lockdown-banner" class="error hidden" style="display:block;margin-bottom:16px;">
This place is in lockdown - every reader here is disabled until it's lifted.
</div>
<div class="card">
<h3>Name</h3>
<form id="set-place-name-form" class="inline-form">

View file

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

View file

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

View file

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

View file

@ -43,8 +43,19 @@ router.get('/:placeId', (req, res) => {
return res.status(500).json({ success: false, message: "Internal server error" });
}
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" });
}
// 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.
@ -53,7 +64,7 @@ router.get('/:placeId', (req, res) => {
name: ap.name,
unlockTime: ap.unlockTime,
config: {
active: !!ap.enabled,
active: lockdownActive ? false : !!ap.enabled,
armed,
scanData: {
ready: JSON.parse(ap.readyData || '{}'),
@ -64,11 +75,12 @@ router.get('/:placeId', (req, res) => {
return acc;
}, {})
};
console.log(resp)
// console.log(resp)
res.json(resp);
});
});
});
});
router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
// This one DOES check api key.
@ -107,6 +119,35 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
return res.status(404).json({ success: false, message: "Access point not found" });
}
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" });
}
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 || '{}')
});
}
db.all(`SELECT * FROM acl WHERE accessPoint = ?`, [accessPointId], async (err, aclEntries) => {
if (err) {
console.error('Failed to retrieve ACL entries:', err.message);
@ -141,7 +182,7 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
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);
// 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) {
@ -203,3 +244,4 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
});
});
});
});

View file

@ -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) => {