Fix duplicate onboarding client names

This commit is contained in:
2026-07-21 02:03:09 +01:00
parent 6416dbfd99
commit dbc4d166c5
5 changed files with 125 additions and 77 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.3.3", "version": "1.3.4",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media uploads", "description": "Pulse Signage application with MySQL and media uploads",
"repository": { "repository": {
+43 -1
View File
@@ -1,3 +1,5 @@
const crypto = require('crypto');
function normalizeClientName(value) { function normalizeClientName(value) {
return String(value || '').trim(); return String(value || '').trim();
} }
@@ -68,9 +70,49 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
} }
} }
function buildClientNameLockName(clientName) {
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
}
async function withClientNameReservation(pool, clientName, handler) {
if (!pool || typeof pool.getConnection !== 'function') {
return handler();
}
const normalizedName = normalizeClientName(clientName);
if (!normalizedName) {
return handler();
}
const connection = await pool.getConnection();
const lockName = buildClientNameLockName(normalizedName);
let lockAcquired = false;
try {
const [lockRows] = await connection.query('SELECT GET_LOCK(?, 5) AS lock_result', [lockName]);
const lockResult = lockRows && lockRows[0] ? Number(lockRows[0].lock_result) : 0;
if (lockResult !== 1) {
const error = new Error('Client name is busy. Please try again.');
error.statusCode = 409;
throw error;
}
lockAcquired = true;
return await handler();
} finally {
if (lockAcquired) {
try {
await connection.query('SELECT RELEASE_LOCK(?)', [lockName]);
} catch (_error) {}
}
connection.release();
}
}
module.exports = { module.exports = {
normalizeClientName: normalizeClientName, normalizeClientName: normalizeClientName,
normalizeDeviceId: normalizeDeviceId, normalizeDeviceId: normalizeDeviceId,
collectLiveConnections: collectLiveConnections, collectLiveConnections: collectLiveConnections,
isClientNameAvailable: isClientNameAvailable isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation
}; };
+4 -4
View File
@@ -1,4 +1,4 @@
const { isClientNameAvailable } = require('../client-name-check'); const { isClientNameAvailable, withClientNameReservation } = require('../client-name-check');
const { isTransientDbError } = require('./onboarding-store'); const { isTransientDbError } = require('./onboarding-store');
function normalizeDeviceId(value) { function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128); return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
@@ -48,15 +48,14 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
throw new Error('Screen is required.'); throw new Error('Screen is required.');
} }
return withClientNameReservation(pool, normalizedClientName, async function () {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]); const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]);
if (!screenRows.length) { if (!screenRows.length) {
throw new Error('Screen not found.'); throw new Error('Screen not found.');
} }
const screen = screenRows[0]; const screen = screenRows[0];
const available = typeof isClientNameAvailableOnScreen === 'function' const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections);
? await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections)
: true;
if (!available) { if (!available) {
const error = new Error('Client name already exists.'); const error = new Error('Client name already exists.');
error.statusCode = 400; error.statusCode = 400;
@@ -69,6 +68,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
); );
return getOnboardingStatus(pool, normalizedDeviceId); return getOnboardingStatus(pool, normalizedDeviceId);
});
} }
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) { async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
+3 -2
View File
@@ -16,7 +16,7 @@ const registerAdminScreenCommandRoutes = require('./web/routes/admin-screen-comm
const registerAdminContentRoutes = require('./web/routes/admin-content'); const registerAdminContentRoutes = require('./web/routes/admin-content');
const { createWebBootstrap } = require('./web/bootstrap'); const { createWebBootstrap } = require('./web/bootstrap');
const { createPlayerActionService } = require('./web/player-actions'); const { createPlayerActionService } = require('./web/player-actions');
const { isClientNameAvailable } = require('./client-name-check'); const { isClientNameAvailable, withClientNameReservation } = require('./client-name-check');
const { createSessionService } = require('./web/session'); const { createSessionService } = require('./web/session');
const { const {
formatDashboardDate, formatDashboardDate,
@@ -179,7 +179,8 @@ async function start() {
pool: pool, pool: pool,
forwardPlayerCommand: playerActionService.forwardPlayerCommand, forwardPlayerCommand: playerActionService.forwardPlayerCommand,
getScreenConnections: playerActionService.getScreenConnections, getScreenConnections: playerActionService.getScreenConnections,
isClientNameAvailable: isClientNameAvailable isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation
}); });
registerAdminContentRoutes(app, { registerAdminContentRoutes(app, {
+8 -3
View File
@@ -3,6 +3,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
const forwardPlayerCommand = deps.forwardPlayerCommand; const forwardPlayerCommand = deps.forwardPlayerCommand;
const getScreenConnections = deps.getScreenConnections; const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable; const isClientNameAvailable = deps.isClientNameAvailable;
const withClientNameReservation = deps.withClientNameReservation;
app.post('/admin/screens/:slug/commands', async function (req, res, next) { app.post('/admin/screens/:slug/commands', async function (req, res, next) {
try { try {
@@ -56,6 +57,11 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
unchanged: true unchanged: true
}); });
} }
if (typeof withClientNameReservation !== 'function') {
return res.status(500).json({ error: 'Client name reservation is unavailable.' });
}
return withClientNameReservation(pool, clientName, async function () {
let liveConnections = []; let liveConnections = [];
try { try {
const [screenSlugs] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC'); const [screenSlugs] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
@@ -76,9 +82,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
liveConnections = []; liveConnections = [];
} }
const available = typeof isClientNameAvailable === 'function' const available = await isClientNameAvailable(pool, clientName, deviceId, liveConnections);
? await isClientNameAvailable(pool, clientName, deviceId, liveConnections)
: true;
if (!available) { if (!available) {
return res.status(409).json({ error: 'Client name already exists.' }); return res.status(409).json({ error: 'Client name already exists.' });
} }
@@ -123,6 +127,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
clientName: clientName, clientName: clientName,
ok: true ok: true
}); });
});
} }
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body) const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)