Feat: Lockdown system; Fix: Generated API Keys borked
This commit is contained in:
parent
179103f1cc
commit
5bcda71741
|
|
@ -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.
|
||||
|
|
|
|||
11
migrations/0006_lockdown.sql
Normal file
11
migrations/0006_lockdown.sql
Normal 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);
|
||||
6
migrations/0007_place_lockdown.sql
Normal file
6
migrations/0007_place_lockdown.sql
Normal 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;
|
||||
|
|
@ -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…</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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,10 +75,11 @@ router.get('/:placeId', (req, res) => {
|
|||
return acc;
|
||||
}, {})
|
||||
};
|
||||
console.log(resp)
|
||||
// console.log(resp)
|
||||
res.json(resp);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
|
||||
|
|
@ -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) {
|
||||
|
|
@ -202,4 +243,5 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue