diff --git a/package.json b/package.json index 1f53ccf..dadfa96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "1.3.3", + "version": "1.3.4", "private": false, "description": "Pulse Signage application with MySQL and media uploads", "repository": { diff --git a/src/client-name-check.js b/src/client-name-check.js index 935c64f..11ea3aa 100644 --- a/src/client-name-check.js +++ b/src/client-name-check.js @@ -1,3 +1,5 @@ +const crypto = require('crypto'); + function normalizeClientName(value) { 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 = { normalizeClientName: normalizeClientName, normalizeDeviceId: normalizeDeviceId, collectLiveConnections: collectLiveConnections, - isClientNameAvailable: isClientNameAvailable + isClientNameAvailable: isClientNameAvailable, + withClientNameReservation: withClientNameReservation }; \ No newline at end of file diff --git a/src/player/onboarding.js b/src/player/onboarding.js index 928e28c..d91cf9e 100644 --- a/src/player/onboarding.js +++ b/src/player/onboarding.js @@ -1,4 +1,4 @@ -const { isClientNameAvailable } = require('../client-name-check'); +const { isClientNameAvailable, withClientNameReservation } = require('../client-name-check'); const { isTransientDbError } = require('./onboarding-store'); function normalizeDeviceId(value) { return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128); @@ -48,27 +48,27 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam throw new Error('Screen is required.'); } - const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]); - if (!screenRows.length) { - throw new Error('Screen not found.'); - } - const screen = screenRows[0]; + return withClientNameReservation(pool, normalizedClientName, async function () { + const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]); + if (!screenRows.length) { + throw new Error('Screen not found.'); + } + const screen = screenRows[0]; - const available = typeof isClientNameAvailableOnScreen === 'function' - ? await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections) - : true; - if (!available) { - const error = new Error('Client name already exists.'); - error.statusCode = 400; - throw error; - } + const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections); + if (!available) { + const error = new Error('Client name already exists.'); + error.statusCode = 400; + throw error; + } - await pool.query( - 'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP', - [normalizedDeviceId, normalizedClientName, screen.id] - ); + await pool.query( + 'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP', + [normalizedDeviceId, normalizedClientName, screen.id] + ); - return getOnboardingStatus(pool, normalizedDeviceId); + return getOnboardingStatus(pool, normalizedDeviceId); + }); } async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) { diff --git a/src/web.js b/src/web.js index 9c52826..af81c02 100644 --- a/src/web.js +++ b/src/web.js @@ -16,7 +16,7 @@ const registerAdminScreenCommandRoutes = require('./web/routes/admin-screen-comm const registerAdminContentRoutes = require('./web/routes/admin-content'); const { createWebBootstrap } = require('./web/bootstrap'); 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 { formatDashboardDate, @@ -179,7 +179,8 @@ async function start() { pool: pool, forwardPlayerCommand: playerActionService.forwardPlayerCommand, getScreenConnections: playerActionService.getScreenConnections, - isClientNameAvailable: isClientNameAvailable + isClientNameAvailable: isClientNameAvailable, + withClientNameReservation: withClientNameReservation }); registerAdminContentRoutes(app, { diff --git a/src/web/routes/admin-screen-commands.js b/src/web/routes/admin-screen-commands.js index e277cc7..cac5a74 100644 --- a/src/web/routes/admin-screen-commands.js +++ b/src/web/routes/admin-screen-commands.js @@ -3,6 +3,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) { const forwardPlayerCommand = deps.forwardPlayerCommand; const getScreenConnections = deps.getScreenConnections; const isClientNameAvailable = deps.isClientNameAvailable; + const withClientNameReservation = deps.withClientNameReservation; app.post('/admin/screens/:slug/commands', async function (req, res, next) { try { @@ -56,40 +57,66 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) { unchanged: true }); } - let liveConnections = []; - try { - const [screenSlugs] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC'); - const liveResults = await Promise.all((screenSlugs || []).map(async function (row) { - const screenSlug = String(row && row.slug ? row.slug : '').trim(); - if (!screenSlug || typeof getScreenConnections !== 'function') { - return []; - } - try { - const liveResponse = await getScreenConnections(screenSlug); - return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : []; - } catch (_error) { - return []; - } - })); - liveConnections = liveResults.flat(); - } catch (_error) { - liveConnections = []; + if (typeof withClientNameReservation !== 'function') { + return res.status(500).json({ error: 'Client name reservation is unavailable.' }); } - const available = typeof isClientNameAvailable === 'function' - ? await isClientNameAvailable(pool, clientName, deviceId, liveConnections) - : true; - if (!available) { - return res.status(409).json({ error: 'Client name already exists.' }); - } + return withClientNameReservation(pool, clientName, async function () { + let liveConnections = []; + try { + const [screenSlugs] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC'); + const liveResults = await Promise.all((screenSlugs || []).map(async function (row) { + const screenSlug = String(row && row.slug ? row.slug : '').trim(); + if (!screenSlug || typeof getScreenConnections !== 'function') { + return []; + } + try { + const liveResponse = await getScreenConnections(screenSlug); + return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : []; + } catch (_error) { + return []; + } + })); + liveConnections = liveResults.flat(); + } catch (_error) { + liveConnections = []; + } - if (!onboardingRow) { - await forwardPlayerCommand(slug, { - command: command, - clientName: clientName, - clientId: connectionId || deviceId || null, - deviceId: deviceId || null - }, connectionId || deviceId || undefined); + const available = await isClientNameAvailable(pool, clientName, deviceId, liveConnections); + if (!available) { + return res.status(409).json({ error: 'Client name already exists.' }); + } + + if (!onboardingRow) { + await forwardPlayerCommand(slug, { + command: command, + clientName: clientName, + clientId: connectionId || deviceId || null, + deviceId: deviceId || null + }, connectionId || deviceId || undefined); + + return res.json({ + screen: screenRows[0], + screenSlug: slug, + command: command, + connectionId: connectionId || null, + deviceId: deviceId, + clientName: clientName, + ok: true, + liveOnly: true + }); + } + + const [updateResult] = await pool.query( + `UPDATE player_onboarding_devices pod + JOIN screens s ON s.id = pod.screen_id + SET pod.client_name = ?, pod.modified_at = CURRENT_TIMESTAMP + WHERE s.slug = ? AND pod.device_id = ?`, + [clientName, slug, deviceId] + ); + if (!updateResult.affectedRows) { + return res.status(404).json({ error: 'Client not found' }); + } return res.json({ screen: screenRows[0], @@ -98,30 +125,8 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) { connectionId: connectionId || null, deviceId: deviceId, clientName: clientName, - ok: true, - liveOnly: true + ok: true }); - } - - const [updateResult] = await pool.query( - `UPDATE player_onboarding_devices pod - JOIN screens s ON s.id = pod.screen_id - SET pod.client_name = ?, pod.modified_at = CURRENT_TIMESTAMP - WHERE s.slug = ? AND pod.device_id = ?`, - [clientName, slug, deviceId] - ); - if (!updateResult.affectedRows) { - return res.status(404).json({ error: 'Client not found' }); - } - - return res.json({ - screen: screenRows[0], - screenSlug: slug, - command: command, - connectionId: connectionId || null, - deviceId: deviceId, - clientName: clientName, - ok: true }); }