diff --git a/frontend/js/utils.js b/frontend/js/utils.js new file mode 100644 index 0000000..fb88256 --- /dev/null +++ b/frontend/js/utils.js @@ -0,0 +1,5 @@ +// HTML escape helper — prevents XSS when inserting user data into innerHTML +export function esc(str) { + if (str == null) return ''; + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); +} diff --git a/frontend/js/views/activity.js b/frontend/js/views/activity.js index e838e02..f9c9bc0 100644 --- a/frontend/js/views/activity.js +++ b/frontend/js/views/activity.js @@ -1,4 +1,5 @@ import { showToast } from '../components/toast.js'; +import { esc } from '../utils.js'; const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json()); @@ -39,10 +40,10 @@ export async function render(container) {
${err.message}
`; } + } catch (err) { el.innerHTML = `${esc(err.message)}
`; } } async function loadPlans() { @@ -146,7 +147,7 @@ async function loadPlans() { `; - } catch (err) { el.innerHTML = `${err.message}
`; } + } catch (err) { el.innerHTML = `${esc(err.message)}
`; } } async function loadSystem() { @@ -164,7 +165,7 @@ async function loadSystem() { Server Status `; - } catch (err) { el.innerHTML = `${err.message}
`; } + } catch (err) { el.innerHTML = `${esc(err.message)}
`; } } export function cleanup() {} diff --git a/frontend/js/views/billing.js b/frontend/js/views/billing.js index 2de911d..526a5f0 100644 --- a/frontend/js/views/billing.js +++ b/frontend/js/views/billing.js @@ -1,5 +1,6 @@ import { api } from '../api.js'; import { showToast } from '../components/toast.js'; +import { esc } from '../utils.js'; export async function render(container) { container.innerHTML = ` @@ -140,7 +141,7 @@ export async function render(container) { } } catch (err) { - document.getElementById('billingContent').innerHTML = `${err.message}
${esc(err.message)}
${err.message}
${esc(err.message)}
${err.message}
${esc(err.message)}
${err.message}
${esc(err.message)}
${err.message}
${esc(err.message)}
${err.message}
`; + el.innerHTML = `${esc(err.message)}
`; } } diff --git a/server/db/database.js b/server/db/database.js index f677cfe..11d67b6 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -58,6 +58,8 @@ const migrations = [ "ALTER TABLE devices ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL", "ALTER TABLE schedules ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL", "ALTER TABLE playlists ADD COLUMN is_auto_generated INTEGER NOT NULL DEFAULT 0", + // Device authentication token + "ALTER TABLE devices ADD COLUMN device_token TEXT", ]; for (const sql of migrations) { try { db.exec(sql); } catch (e) { /* already exists */ } diff --git a/server/db/schema.sql b/server/db/schema.sql index 3e44f50..dc983f5 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -382,6 +382,15 @@ CREATE TABLE IF NOT EXISTS kiosk_pages ( updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); +-- ===================== DEVICE STATUS LOG ===================== + +CREATE TABLE IF NOT EXISTS device_status_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + status TEXT NOT NULL, + timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + -- ===================== DEVICE FINGERPRINTS ===================== CREATE TABLE IF NOT EXISTS device_fingerprints ( diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 24b7269..afae556 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -6,12 +6,12 @@ function generateToken(user) { return jwt.sign( { id: user.id, email: user.email, role: user.role }, config.jwtSecret, - { expiresIn: config.jwtExpiry } + { algorithm: 'HS256', expiresIn: config.jwtExpiry } ); } function verifyToken(token) { - return jwt.verify(token, config.jwtSecret); + return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] }); } // Express middleware - requires valid JWT diff --git a/server/routes/auth.js b/server/routes/auth.js index b0a0811..9e1f610 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -108,7 +108,12 @@ router.post('/google', async (req, res) => { user = db.prepare('SELECT * FROM users WHERE id = ?').get(id); } else if (user.auth_provider !== 'google') { - // Link Google to existing account + // Existing account with different provider — do NOT silently overwrite auth_provider. + // If they have a local password, require them to log in locally and link from settings. + if (user.password_hash) { + return res.status(409).json({ error: 'An account with this email already exists. Please log in with your password.' }); + } + // No password (e.g. Microsoft → Google switch) — allow linking db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?') .run('google', googleId, picture || user.avatar_url, user.id); user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); @@ -178,6 +183,10 @@ router.post('/microsoft', async (req, res) => { user = db.prepare('SELECT * FROM users WHERE id = ?').get(id); } else if (user.auth_provider !== 'microsoft') { + // Existing account with different provider — do NOT silently overwrite auth_provider. + if (user.password_hash) { + return res.status(409).json({ error: 'An account with this email already exists. Please log in with your password.' }); + } db.prepare('UPDATE users SET auth_provider = ?, provider_id = ? WHERE id = ?') .run('microsoft', microsoftId, user.id); user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); @@ -223,7 +232,7 @@ router.put('/me', requireAuth, (req, res) => { db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') .run(name, req.user.id); } - if (password && password.length >= 6) { + if (password && password.length >= 8) { const hash = bcrypt.hashSync(password, 10); db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') .run(hash, req.user.id); diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js index 790624f..683fa23 100644 --- a/server/routes/device-groups.js +++ b/server/routes/device-groups.js @@ -109,6 +109,10 @@ router.post('/:id/assign-content', requireGroupOwnership, (req, res) => { const { content_id, duration_sec } = req.body; if (!content_id) return res.status(400).json({ error: 'content_id required' }); + // Verify content belongs to the user + const content = db.prepare('SELECT id FROM content WHERE id = ? AND user_id = ?').get(content_id, req.user.id); + if (!content) return res.status(404).json({ error: 'Content not found' }); + const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id); const transaction = db.transaction(() => { diff --git a/server/routes/layouts.js b/server/routes/layouts.js index 6d153b0..d3dacd8 100644 --- a/server/routes/layouts.js +++ b/server/routes/layouts.js @@ -24,10 +24,20 @@ router.get('/', (req, res) => { res.json(layouts); }); +// Helper: check layout access (owner, admin, or template) +function checkLayoutAccess(req, res) { + const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); + if (!layout) { res.status(404).json({ error: 'Layout not found' }); return null; } + if (!layout.is_template && !['admin','superadmin'].includes(req.user.role) && layout.user_id !== req.user.id) { + res.status(403).json({ error: 'Access denied' }); return null; + } + return layout; +} + // Get layout with zones router.get('/:id', (req, res) => { - const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); - if (!layout) return res.status(404).json({ error: 'Layout not found' }); + const layout = checkLayoutAccess(req, res); + if (!layout) return; layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id); res.json(layout); @@ -62,8 +72,8 @@ router.post('/', (req, res) => { // Update layout router.put('/:id', (req, res) => { - const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); - if (!layout) return res.status(404).json({ error: 'Layout not found' }); + const layout = checkLayoutAccess(req, res); + if (!layout) return; if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' }); const { name, width, height } = req.body; @@ -78,8 +88,8 @@ router.put('/:id', (req, res) => { // Delete layout router.delete('/:id', (req, res) => { - const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); - if (!layout) return res.status(404).json({ error: 'Layout not found' }); + const layout = checkLayoutAccess(req, res); + if (!layout) return; if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot delete templates' }); db.prepare('DELETE FROM layouts WHERE id = ?').run(req.params.id); @@ -88,8 +98,8 @@ router.delete('/:id', (req, res) => { // Add zone to layout router.post('/:id/zones', (req, res) => { - const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); - if (!layout) return res.status(404).json({ error: 'Layout not found' }); + const layout = checkLayoutAccess(req, res); + if (!layout) return; const { name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color } = req.body; const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM layout_zones WHERE layout_id = ?').get(req.params.id).m || 0; @@ -110,6 +120,8 @@ router.post('/:id/zones', (req, res) => { // Update zone router.put('/:id/zones/:zoneId', (req, res) => { + const layout = checkLayoutAccess(req, res); + if (!layout) return; const zone = db.prepare('SELECT * FROM layout_zones WHERE id = ? AND layout_id = ?').get(req.params.zoneId, req.params.id); if (!zone) return res.status(404).json({ error: 'Zone not found' }); @@ -132,6 +144,8 @@ router.put('/:id/zones/:zoneId', (req, res) => { // Delete zone router.delete('/:id/zones/:zoneId', (req, res) => { + const layout = checkLayoutAccess(req, res); + if (!layout) return; db.prepare('DELETE FROM layout_zones WHERE id = ? AND layout_id = ?').run(req.params.zoneId, req.params.id); db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); res.json({ success: true }); @@ -139,8 +153,8 @@ router.delete('/:id/zones/:zoneId', (req, res) => { // Duplicate layout (for using templates) router.post('/:id/duplicate', (req, res) => { - const source = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); - if (!source) return res.status(404).json({ error: 'Layout not found' }); + const source = checkLayoutAccess(req, res); + if (!source) return; const newId = uuidv4(); const name = req.body.name || `${source.name} (Copy)`; @@ -166,6 +180,9 @@ router.post('/:id/duplicate', (req, res) => { // Assign layout to device router.put('/device/:deviceId', (req, res) => { + const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); + if (!device) return res.status(404).json({ error: 'Device not found' }); + if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); const { layout_id } = req.body; db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?") .run(layout_id || null, req.params.deviceId); diff --git a/server/routes/schedules.js b/server/routes/schedules.js index 4809190..82a9c68 100644 --- a/server/routes/schedules.js +++ b/server/routes/schedules.js @@ -17,8 +17,12 @@ router.get('/', (req, res) => { res.json(db.prepare(sql).all(...params)); }); -// Get schedules for a device +// Get schedules for a device (verify device belongs to user) router.get('/device/:deviceId', (req, res) => { + const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); + if (!device) return res.status(404).json({ error: 'Device not found' }); + if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); + const schedules = db.prepare(` SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name FROM schedules s @@ -36,6 +40,11 @@ router.get('/week', (req, res) => { const { date, device_id } = req.query; if (!device_id) return res.status(400).json({ error: 'device_id required' }); + // Verify device ownership + const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); + const weekStart = date ? new Date(date) : new Date(); weekStart.setHours(0, 0, 0, 0); weekStart.setDate(weekStart.getDate() - weekStart.getDay()); diff --git a/server/routes/stripe.js b/server/routes/stripe.js index 41340b4..8079297 100644 --- a/server/routes/stripe.js +++ b/server/routes/stripe.js @@ -91,11 +91,11 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r let event; try { - if (config.stripeWebhookSecret) { - event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.stripeWebhookSecret); - } else { - event = JSON.parse(req.body.toString()); + if (!config.stripeWebhookSecret) { + console.error('Stripe webhook secret not configured — rejecting unsigned webhook'); + return res.status(400).json({ error: 'Webhook secret not configured' }); } + event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.stripeWebhookSecret); } catch (err) { console.error('Webhook signature verification failed:', err.message); return res.status(400).json({ error: 'Invalid signature' }); diff --git a/server/routes/video-walls.js b/server/routes/video-walls.js index e585acf..7632bb3 100644 --- a/server/routes/video-walls.js +++ b/server/routes/video-walls.js @@ -143,6 +143,8 @@ router.put('/:id/devices', (req, res) => { // Set wall content router.put('/:id/content', (req, res) => { + const wall = checkWallAccess(req, res); + if (!wall) return; const { content_id } = req.body; db.prepare("UPDATE video_walls SET content_id = ?, updated_at = strftime('%s','now') WHERE id = ?") .run(content_id || null, req.params.id); @@ -151,8 +153,8 @@ router.put('/:id/content', (req, res) => { // Get wall config for a specific device (used by Android app) router.get('/:id/device-config/:deviceId', (req, res) => { - const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id); - if (!wall) return res.status(404).json({ error: 'Wall not found' }); + const wall = checkWallAccess(req, res); + if (!wall) return; const position = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ? AND device_id = ?') .get(req.params.id, req.params.deviceId); diff --git a/server/server.js b/server/server.js index f4b56c2..5ef3928 100644 --- a/server/server.js +++ b/server/server.js @@ -39,6 +39,7 @@ const helmet = require('helmet'); app.use(helmet({ contentSecurityPolicy: false, // Allow inline scripts in widget renders crossOriginEmbedderPolicy: false, // Allow loading external widget content + hsts: { maxAge: 31536000, includeSubDomains: true }, })); // CORS: open for public content (kiosk, widgets, player, uploads), restricted for API app.use(cors({ @@ -123,6 +124,10 @@ app.use('/api/auth/register', rateLimit(60000, 5)); // 5 registrations per minut app.use('/api/auth', require('./routes/auth')); // Rate limit pairing to prevent brute force (5 attempts per minute per IP) app.use('/api/provision/pair', rateLimit(60000, 5)); +// Rate limit expensive operations +app.use('/api/status/export', rateLimit(60000, 5)); // 5 exports per minute +app.use('/api/status/import', rateLimit(60000, 3)); // 3 imports per minute +app.use('/api/content', rateLimit(60000, 30)); // 30 content operations per minute // Subscription routes (mixed auth) app.use('/api/subscription', require('./routes/subscription')); @@ -148,7 +153,7 @@ app.get('/api/devices/:id/screenshot', (req, res) => { const { db: sdb } = require('./db/database'); const device = sdb.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.id); if (!device) return res.status(404).json({ error: 'Device not found' }); - if (user.role !== 'admin' && device.user_id && device.user_id !== user.id) return res.status(403).json({ error: 'Access denied' }); + if (!['admin','superadmin'].includes(user.role) && device.user_id && device.user_id !== user.id) return res.status(403).json({ error: 'Access denied' }); // Serve from memory if available (device online), otherwise from disk (offline snapshot) const deviceSocket = require('./ws/deviceSocket'); const memScreenshot = deviceSocket.lastScreenshots?.[req.params.id]; @@ -171,8 +176,8 @@ app.get('/api/content/:id/file', (req, res) => { const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id); if (!content) return res.status(404).json({ error: 'Content not found' }); if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' }); - const assigned = db.prepare('SELECT id FROM assignments WHERE content_id = ? LIMIT 1').get(req.params.id); - if (!assigned) return res.status(403).json({ error: 'Content not assigned to any device' }); + const assigned = db.prepare('SELECT id FROM playlist_items WHERE content_id = ? LIMIT 1').get(req.params.id); + if (!assigned) return res.status(403).json({ error: 'Content not assigned to any playlist' }); const safePath = path.resolve(config.contentDir, path.basename(content.filepath)); if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); res.sendFile(safePath); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 905a0c0..d5479fb 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -1,4 +1,5 @@ const { v4: uuidv4 } = require('uuid'); +const crypto = require('crypto'); const path = require('path'); const fs = require('fs'); const { db, pruneTelemetry, pruneScreenshots } = require('../db/database'); @@ -9,6 +10,24 @@ const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription' // In-memory store for latest screenshot per device (avoids disk writes during streaming) let lastScreenshots = {}; +// Generate a random device token +function generateDeviceToken() { + return crypto.randomBytes(32).toString('hex'); +} + +// Validate device_id + device_token pair. Returns true if valid. +function validateDeviceToken(deviceId, token) { + if (!deviceId || !token) return false; + const row = db.prepare('SELECT device_token FROM devices WHERE id = ?').get(deviceId); + if (!row || !row.device_token) return false; + // Constant-time comparison to prevent timing attacks + try { + return crypto.timingSafeEqual(Buffer.from(row.device_token), Buffer.from(token)); + } catch { + return false; + } +} + function getClientIp(socket) { const forwarded = socket.handshake.headers['x-forwarded-for']; if (forwarded) return forwarded.split(',')[0].trim(); @@ -102,16 +121,18 @@ module.exports = function setupDeviceSocket(io) { // Expose helpers for use by route handlers module.exports.lastScreenshots = lastScreenshots; module.exports.buildPlaylistPayload = buildPlaylistPayload; + module.exports.generateDeviceToken = generateDeviceToken; const deviceNs = io.of('/device'); const dashboardNs = io.of('/dashboard'); deviceNs.on('connection', (socket) => { console.log(`Device socket connected: ${socket.id}`); let currentDeviceId = null; + let authenticated = false; // Track whether this socket has been authenticated - // Device registers with a pairing code (first time) or device_id (reconnect) + // Device registers with a pairing code (first time) or device_id + device_token (reconnect) socket.on('device:register', (data) => { - const { pairing_code, device_id, device_info, fingerprint } = data; + const { pairing_code, device_id, device_token, device_info, fingerprint } = data; // Track device fingerprint to prevent reinstall abuse if (fingerprint) { @@ -125,8 +146,16 @@ module.exports = function setupDeviceSocket(io) { // Someone reinstalled - link them back to existing device const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id); if (oldDevice) { + // Validate token if the old device has one + if (oldDevice.device_token && !validateDeviceToken(existing.device_id, device_token)) { + console.warn(`Fingerprint match but invalid token for device ${existing.device_id}`); + // Generate a new token — the reinstalled app needs a fresh one via re-pairing + socket.emit('device:unpaired', { reason: 'invalid_token' }); + return; + } console.log(`Fingerprint match: linking to existing device ${existing.device_id}`); - socket.emit('device:registered', { device_id: existing.device_id, status: oldDevice.status }); + authenticated = true; + socket.emit('device:registered', { device_id: existing.device_id, device_token: oldDevice.device_token, status: oldDevice.status }); currentDeviceId = existing.device_id; heartbeat.registerConnection(existing.device_id, socket.id); socket.join(existing.device_id); @@ -150,13 +179,28 @@ module.exports = function setupDeviceSocket(io) { } if (device_id) { - // Reconnecting known device + // Reconnecting known device — require valid token const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id); if (device) { + // Validate device token (skip for legacy devices that don't have a token yet) + if (device.device_token && !validateDeviceToken(device_id, device_token)) { + console.warn(`Invalid device token for ${device_id} from ${getClientIp(socket)}`); + socket.emit('device:auth-error', { error: 'Invalid device token' }); + return; + } + currentDeviceId = device_id; + authenticated = true; db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?") .run(getClientIp(socket), device_id); + // Generate token for legacy devices that don't have one yet + let tokenToSend = device.device_token; + if (!tokenToSend) { + tokenToSend = generateDeviceToken(); + db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(tokenToSend, device_id); + } + if (device_info) { db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ? WHERE id = ?') .run(device_info.android_version, device_info.app_version, device_info.screen_width, device_info.screen_height, device_id); @@ -164,7 +208,7 @@ module.exports = function setupDeviceSocket(io) { heartbeat.registerConnection(device_id, socket.id); socket.join(device_id); - socket.emit('device:registered', { device_id, status: 'online' }); + socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' }); logDeviceStatus(device_id, 'online'); // Check subscription/trial status before sending playlist @@ -187,15 +231,17 @@ module.exports = function setupDeviceSocket(io) { } if (pairing_code) { - // New device registering with pairing code + // New device registering with pairing code — generate a device_token const id = uuidv4(); + const newToken = generateDeviceToken(); currentDeviceId = id; + authenticated = true; db.prepare(` - INSERT INTO devices (id, pairing_code, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat) - VALUES (?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now')) + INSERT INTO devices (id, pairing_code, device_token, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat) + VALUES (?, ?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now')) `).run( - id, pairing_code, getClientIp(socket), + id, pairing_code, newToken, getClientIp(socket), device_info?.android_version || null, device_info?.app_version || null, device_info?.screen_width || null, @@ -204,17 +250,27 @@ module.exports = function setupDeviceSocket(io) { heartbeat.registerConnection(id, socket.id); socket.join(id); - socket.emit('device:registered', { device_id: id, status: 'provisioning' }); + socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' }); dashboardNs.emit('dashboard:device-added', db.prepare('SELECT * FROM devices WHERE id = ?').get(id)); console.log(`New device registered: ${id} with pairing code: ${pairing_code}`); } }); + // Require authentication for all events after register + function requireDeviceAuth() { + if (!authenticated || !currentDeviceId) { + socket.emit('device:auth-error', { error: 'Not authenticated. Send device:register first.' }); + return false; + } + return true; + } + // Heartbeat with telemetry socket.on('device:heartbeat', (data) => { + if (!requireDeviceAuth()) return; const { device_id, telemetry } = data; - if (!device_id) return; + if (!device_id || device_id !== currentDeviceId) return; currentDeviceId = device_id; heartbeat.updateHeartbeat(device_id); @@ -252,8 +308,11 @@ module.exports = function setupDeviceSocket(io) { // Screenshot received from device - relay via WebSocket, keep latest in memory socket.on('device:screenshot', (data) => { + if (!requireDeviceAuth()) return; const { device_id, image_b64 } = data; - if (!device_id || !image_b64) return; + if (!device_id || device_id !== currentDeviceId || !image_b64) return; + // Validate screenshot size (max 2MB base64 ≈ 1.5MB image) + if (image_b64.length > 2 * 1024 * 1024) return; // Store latest screenshot in memory (for Now Playing preview and offline snapshot) if (!lastScreenshots) lastScreenshots = {}; @@ -273,19 +332,24 @@ module.exports = function setupDeviceSocket(io) { // Content download acknowledgement socket.on('device:content-ack', (data) => { + if (!requireDeviceAuth()) return; const { device_id, content_id, status } = data; + if (device_id !== currentDeviceId) return; console.log(`Device ${device_id} content ${content_id}: ${status}`); dashboardNs.emit('dashboard:content-ack', { device_id, content_id, status }); }); // Playback state update socket.on('device:playback-state', (data) => { + if (!requireDeviceAuth()) return; dashboardNs.emit('dashboard:playback-state', data); }); // Play event logging (proof-of-play) socket.on('device:play-event', (data) => { + if (!requireDeviceAuth()) return; const { device_id, event, content_id, content_name, zone_id, completed } = data; + if (device_id !== currentDeviceId) return; try { if (event === 'play_start') { db.prepare(` @@ -310,6 +374,7 @@ module.exports = function setupDeviceSocket(io) { // Video wall sync relay socket.on('wall:sync', (data) => { + if (!requireDeviceAuth()) return; // Relay to all devices in the same wall const wallDevices = db.prepare( 'SELECT device_id FROM video_wall_devices WHERE wall_id = ? AND device_id != ?'