| | const http = require('http'); |
| | const fs = require('fs'); |
| | const path = require('path'); |
| | const { WebSocketServer, WebSocket } = require('ws'); |
| | const { v4: uuidv4 } = require('uuid'); |
| | const crypto = require('crypto'); |
| |
|
| | |
| | |
| | const SECRET = process.env.Key || ''; |
| | if (!SECRET) console.warn('[Auth] WARNING: Environment variable "Key" is not set. Admin login will be disabled.'); |
| |
|
| | |
| | |
| | |
| | const activeSessions = new Set(); |
| |
|
| | function createSession() { |
| | const nonce = crypto.randomBytes(32).toString('hex'); |
| | const token = crypto.createHmac('sha256', SECRET).update(nonce).digest('hex'); |
| | activeSessions.add(token); |
| | return token; |
| | } |
| |
|
| | function isValidSession(token) { |
| | return typeof token === 'string' && activeSessions.has(token); |
| | } |
| |
|
| | |
| | const sounds = {}; |
| | const notifications = {}; |
| | const devices = {}; |
| |
|
| | const deviceClients = {}; |
| | const adminClients = new Set(); |
| |
|
| | const MIME = { |
| | '.html': 'text/html', |
| | '.js': 'application/javascript', |
| | '.css': 'text/css', |
| | '.json': 'application/json', |
| | '.png': 'image/png', |
| | '.ico': 'image/x-icon', |
| | }; |
| |
|
| | |
| | const httpServer = http.createServer((req, res) => { |
| | let urlPath = req.url.split('?')[0]; |
| | if (urlPath === '/' || urlPath === '') urlPath = '/index.html'; |
| | if (urlPath === '/admin' || urlPath === '/admin/') urlPath = '/admin/index.html'; |
| |
|
| | const filePath = path.join(__dirname, 'public', urlPath); |
| |
|
| | |
| | const publicDir = path.resolve(__dirname, 'public'); |
| | const resolved = path.resolve(filePath); |
| | if (!resolved.startsWith(publicDir)) { |
| | res.writeHead(403); res.end('Forbidden'); return; |
| | } |
| |
|
| | const ext = path.extname(filePath); |
| | const mime = MIME[ext] || 'application/octet-stream'; |
| |
|
| | fs.readFile(filePath, (err, data) => { |
| | if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); return; } |
| | res.writeHead(200, { 'Content-Type': mime }); |
| | res.end(data); |
| | }); |
| | }); |
| |
|
| | |
| | const wss = new WebSocketServer({ server: httpServer }); |
| |
|
| | wss.on('connection', (ws, req) => { |
| | const urlPath = req.url || '/'; |
| | if (urlPath.startsWith('/admin-ws')) { |
| | handleAdminConnection(ws); |
| | } else { |
| | handleDeviceConnection(ws); |
| | } |
| | }); |
| |
|
| | |
| | function send(ws, msg) { |
| | if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); |
| | } |
| |
|
| | function broadcastAdmin(msg) { |
| | for (const ws of adminClients) send(ws, msg); |
| | } |
| |
|
| | function broadcastDevice(uuid, msg) { |
| | const ws = deviceClients[uuid]; |
| | if (ws) send(ws, msg); |
| | } |
| |
|
| | function broadcastAllDevices(msg) { |
| | for (const uuid of Object.keys(deviceClients)) broadcastDevice(uuid, msg); |
| | } |
| |
|
| | function getFullState() { |
| | return { |
| | sounds: Object.values(sounds), |
| | notifications: Object.values(notifications), |
| | devices: Object.values(devices).map(devicePublic), |
| | }; |
| | } |
| |
|
| | function devicePublic(d) { |
| | return { |
| | uuid: d.uuid, |
| | name: d.name, |
| | notifications: d.notifications, |
| | lastConnection: d.lastConnection, |
| | schedule: d.schedule || [], |
| | pendingChanges: d.pendingChanges || [], |
| | online: !!deviceClients[d.uuid], |
| | cachedSounds: d.cachedSounds || [], |
| | }; |
| | } |
| |
|
| | |
| | function handleAdminConnection(ws) { |
| | let authenticated = false; |
| |
|
| | |
| | const authTimeout = setTimeout(() => { |
| | if (!authenticated) { |
| | send(ws, { type: 'auth_timeout' }); |
| | ws.close(); |
| | } |
| | }, 10000); |
| |
|
| | ws.on('message', (raw) => { |
| | let msg; |
| | try { msg = JSON.parse(raw); } catch { return; } |
| |
|
| | |
| | if (!authenticated) { |
| | if (msg.type === 'auth_resume' && isValidSession(msg.token)) { |
| | |
| | authenticated = true; |
| | clearTimeout(authTimeout); |
| | adminClients.add(ws); |
| | send(ws, { type: 'auth_ok', token: msg.token }); |
| | send(ws, { type: 'full_state', ...getFullState() }); |
| | console.log('[Admin] session resumed'); |
| | return; |
| | } |
| |
|
| | if (msg.type === 'auth_login') { |
| | if (!SECRET) { |
| | send(ws, { type: 'auth_error', reason: 'Server has no Key configured.' }); |
| | return; |
| | } |
| | |
| | const provided = Buffer.from(String(msg.password || '')); |
| | const expected = Buffer.from(SECRET); |
| | const match = provided.length === expected.length && |
| | crypto.timingSafeEqual(provided, expected); |
| | if (match) { |
| | authenticated = true; |
| | clearTimeout(authTimeout); |
| | const token = createSession(); |
| | adminClients.add(ws); |
| | send(ws, { type: 'auth_ok', token }); |
| | send(ws, { type: 'full_state', ...getFullState() }); |
| | console.log('[Admin] authenticated'); |
| | } else { |
| | send(ws, { type: 'auth_error', reason: 'Invalid password.' }); |
| | console.warn('[Admin] failed login attempt'); |
| | } |
| | return; |
| | } |
| |
|
| | |
| | send(ws, { type: 'auth_required' }); |
| | return; |
| | } |
| |
|
| | |
| | handleAdminMessage(ws, msg); |
| | }); |
| |
|
| | ws.on('close', () => { |
| | adminClients.delete(ws); |
| | clearTimeout(authTimeout); |
| | console.log(`[Admin] disconnected, total=${adminClients.size}`); |
| | }); |
| |
|
| | ws.on('error', (e) => console.error('[Admin] WS error', e.message)); |
| |
|
| | |
| | send(ws, { type: 'auth_required' }); |
| | } |
| |
|
| | function handleAdminMessage(ws, msg) { |
| | switch (msg.type) { |
| |
|
| | case 'create_sound': { |
| | const id = uuidv4(); |
| | const sound = { id, name: msg.name, data: msg.data }; |
| | sounds[id] = sound; |
| | broadcastAdmin({ type: 'sound_added', sound }); |
| | break; |
| | } |
| |
|
| | case 'create_notification': { |
| | const id = uuidv4(); |
| | const notif = { |
| | id, name: msg.name, heading: msg.heading, body: msg.body, |
| | hyperlink: msg.hyperlink || '', displayed: false, soundId: msg.soundId || null, |
| | }; |
| | notifications[id] = notif; |
| | for (const uuid of Object.keys(devices)) { |
| | if (!devices[uuid].notifications.find(n => n.id === id)) |
| | devices[uuid].notifications.push({ ...notif }); |
| | } |
| | broadcastAdmin({ type: 'notification_added', notification: notif }); |
| | broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) }); |
| | broadcastAllDevices({ type: 'notification_added', notification: notif }); |
| | break; |
| | } |
| |
|
| | case 'schedule_notification': { |
| | const device = devices[msg.uuid]; |
| | if (!device) return; |
| | device.schedule = device.schedule || []; |
| | const existing = device.schedule.find(s => s.notificationId === msg.notificationId); |
| | if (existing) { existing.scheduledAt = msg.scheduledAt; } |
| | else { device.schedule.push({ notificationId: msg.notificationId, scheduledAt: msg.scheduledAt }); } |
| | const scheduleMsg = { type: 'schedule_update', schedule: device.schedule }; |
| | if (deviceClients[msg.uuid]) { broadcastDevice(msg.uuid, scheduleMsg); } |
| | else { (device.pendingChanges = device.pendingChanges || []).push(scheduleMsg); } |
| | broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) }); |
| | break; |
| | } |
| |
|
| | case 'play_now': { |
| | const playMsg = { type: 'play_now', notificationId: msg.notificationId }; |
| | if (deviceClients[msg.uuid]) { broadcastDevice(msg.uuid, playMsg); } |
| | else { |
| | const device = devices[msg.uuid]; |
| | if (device) (device.pendingChanges = device.pendingChanges || []).push(playMsg); |
| | } |
| | break; |
| | } |
| |
|
| | case 'update_device_name': { |
| | const device = devices[msg.uuid]; |
| | if (!device) return; |
| | device.name = msg.name; |
| | const nameMsg = { type: 'name_update', name: msg.name }; |
| | if (deviceClients[msg.uuid]) { broadcastDevice(msg.uuid, nameMsg); } |
| | else { (device.pendingChanges = device.pendingChanges || []).push(nameMsg); } |
| | broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) }); |
| | break; |
| | } |
| |
|
| | case 'remove_schedule': { |
| | const device = devices[msg.uuid]; |
| | if (!device) return; |
| | device.schedule = (device.schedule || []).filter(s => s.notificationId !== msg.notificationId); |
| | const rmMsg = { type: 'schedule_update', schedule: device.schedule }; |
| | if (deviceClients[msg.uuid]) { broadcastDevice(msg.uuid, rmMsg); } |
| | else { (device.pendingChanges = device.pendingChanges || []).push(rmMsg); } |
| | broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) }); |
| | break; |
| | } |
| |
|
| | case 'request_sound': { |
| | const sound = sounds[msg.id]; |
| | if (sound) send(ws, { type: 'sound_data', sound }); |
| | break; |
| | } |
| |
|
| | default: |
| | console.warn('[Admin] unknown message type:', msg.type); |
| | } |
| | } |
| |
|
| | |
| | function handleDeviceConnection(ws) { |
| | let deviceUUID = null; |
| |
|
| | ws.on('message', (raw) => { |
| | let msg; |
| | try { msg = JSON.parse(raw); } catch { return; } |
| |
|
| | if (msg.type === 'hello') { |
| | if (msg.uuid && devices[msg.uuid]) { |
| | deviceUUID = msg.uuid; |
| | } else { |
| | deviceUUID = msg.uuid || uuidv4(); |
| | devices[deviceUUID] = { |
| | uuid: deviceUUID, |
| | name: `Device ${Object.keys(devices).length + 1}`, |
| | notifications: Object.values(notifications).map(n => ({ ...n })), |
| | lastConnection: null, schedule: [], pendingChanges: [], |
| | }; |
| | } |
| |
|
| | deviceClients[deviceUUID] = ws; |
| | const device = devices[deviceUUID]; |
| | device.lastConnection = null; |
| |
|
| | if (device.pendingChanges && device.pendingChanges.length > 0) { |
| | for (const change of device.pendingChanges) send(ws, change); |
| | device.pendingChanges = []; |
| | } |
| |
|
| | send(ws, { |
| | type: 'device_init', uuid: deviceUUID, |
| | notifications: device.notifications, schedule: device.schedule, name: device.name, |
| | }); |
| |
|
| | broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) }); |
| | console.log(`[Device] ${deviceUUID} connected (${device.name})`); |
| | return; |
| | } |
| |
|
| | if (!deviceUUID) return; |
| | handleDeviceMessage(ws, deviceUUID, msg); |
| | }); |
| |
|
| | ws.on('close', () => { |
| | if (deviceUUID && devices[deviceUUID]) { |
| | devices[deviceUUID].lastConnection = Date.now(); |
| | delete deviceClients[deviceUUID]; |
| | broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) }); |
| | console.log(`[Device] ${deviceUUID} disconnected`); |
| | } |
| | }); |
| |
|
| | ws.on('error', (e) => console.error('[Device] WS error', e.message)); |
| | } |
| |
|
| | function handleDeviceMessage(ws, uuid, msg) { |
| | const device = devices[uuid]; |
| | if (!device) return; |
| |
|
| | switch (msg.type) { |
| | case 'mark_displayed': { |
| | const notif = device.notifications.find(n => n.id === msg.notificationId); |
| | if (notif) notif.displayed = true; |
| | if (notifications[msg.notificationId]) notifications[msg.notificationId].displayed = true; |
| | broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) }); |
| | break; |
| | } |
| | case 'request_sound': { |
| | const sound = sounds[msg.soundId]; |
| | if (sound) send(ws, { type: 'sound_data', sound }); |
| | break; |
| | } |
| | case 'sync_schedule': { |
| | device.schedule = msg.schedule || []; |
| | broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) }); |
| | break; |
| | } |
| | case 'cached_sounds': { |
| | device.cachedSounds = msg.soundIds || []; |
| | broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) }); |
| | break; |
| | } |
| | default: |
| | console.warn('[Device] unknown message type:', msg.type); |
| | } |
| | } |
| |
|
| | |
| | const PORT = process.env.PORT || 7860; |
| | httpServer.listen(PORT, () => { |
| | console.log(`Server running on http://0.0.0.0:${PORT}`); |
| | }); |