System / server.js
Sebebeb's picture
Auth requirement
e20cd96 verified
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');
// ─── Auth ─────────────────────────────────────────────────────────────────────
// Secret key from environment variable "Key"
const SECRET = process.env.Key || '';
if (!SECRET) console.warn('[Auth] WARNING: Environment variable "Key" is not set. Admin login will be disabled.');
// HMAC-based session tokens: token = hmac(SECRET, nonce+timestamp)
// Sessions are stored server-side; possession of token alone is not enough β€”
// the server must have issued it.
const activeSessions = new Set(); // Set<token>
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);
}
// ─── In-Memory State ──────────────────────────────────────────────────────────
const sounds = {};
const notifications = {};
const devices = {};
const deviceClients = {};
const adminClients = new Set(); // only authenticated WS connections
const MIME = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.ico': 'image/x-icon',
};
// ─── HTTP File Server ─────────────────────────────────────────────────────────
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);
// Prevent path traversal
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);
});
});
// ─── WebSocket Server ─────────────────────────────────────────────────────────
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);
}
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
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 || [],
};
}
// ─── Admin Connection Handler ─────────────────────────────────────────────────
function handleAdminConnection(ws) {
let authenticated = false;
// Give them 10 seconds to authenticate, then close
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; }
// ── Authentication handshake ──────────────────────────────────────────────
if (!authenticated) {
if (msg.type === 'auth_resume' && isValidSession(msg.token)) {
// Client is resuming a valid session
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;
}
// Constant-time comparison to prevent timing attacks
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;
}
// Any other message before auth: reject silently
send(ws, { type: 'auth_required' });
return;
}
// ── Authenticated messages ─────────────────────────────────────────────────
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));
// Prompt client to authenticate
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);
}
}
// ─── Device Connection Handler ────────────────────────────────────────────────
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);
}
}
// ─── Start ────────────────────────────────────────────────────────────────────
const PORT = process.env.PORT || 7860;
httpServer.listen(PORT, () => {
console.log(`Server running on http://0.0.0.0:${PORT}`);
});