| 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}`); |
| }); |