Sebebeb commited on
Commit
e20cd96
Β·
verified Β·
1 Parent(s): a663ee3

Auth requirement

Browse files
Files changed (1) hide show
  1. server.js +117 -68
server.js CHANGED
@@ -3,13 +3,36 @@ const fs = require('fs');
3
  const path = require('path');
4
  const { WebSocketServer, WebSocket } = require('ws');
5
  const { v4: uuidv4 } = require('uuid');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
 
7
  const sounds = {};
8
  const notifications = {};
9
  const devices = {};
10
 
11
  const deviceClients = {};
12
- const adminClients = new Set();
13
 
14
  const MIME = {
15
  '.html': 'text/html',
@@ -20,43 +43,46 @@ const MIME = {
20
  '.ico': 'image/x-icon',
21
  };
22
 
23
- // FIX: removed leftover `serverOptions` argument β€” http.createServer takes no options object
24
  const httpServer = http.createServer((req, res) => {
25
  let urlPath = req.url.split('?')[0];
26
  if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
27
  if (urlPath === '/admin' || urlPath === '/admin/') urlPath = '/admin/index.html';
28
 
29
  const filePath = path.join(__dirname, 'public', urlPath);
 
 
 
 
 
 
 
 
30
  const ext = path.extname(filePath);
31
  const mime = MIME[ext] || 'application/octet-stream';
32
 
33
  fs.readFile(filePath, (err, data) => {
34
- if (err) {
35
- res.writeHead(404, { 'Content-Type': 'text/plain' });
36
- res.end('Not found');
37
- return;
38
- }
39
  res.writeHead(200, { 'Content-Type': mime });
40
  res.end(data);
41
  });
42
  });
43
 
 
44
  const wss = new WebSocketServer({ server: httpServer });
45
 
46
  wss.on('connection', (ws, req) => {
47
  const urlPath = req.url || '/';
48
- const isAdmin = urlPath.startsWith('/admin-ws');
49
- if (isAdmin) {
50
  handleAdminConnection(ws);
51
  } else {
52
  handleDeviceConnection(ws);
53
  }
54
  });
55
 
 
56
  function send(ws, msg) {
57
- if (ws.readyState === WebSocket.OPEN) {
58
- ws.send(JSON.stringify(msg));
59
- }
60
  }
61
 
62
  function broadcastAdmin(msg) {
@@ -89,26 +115,83 @@ function devicePublic(d) {
89
  schedule: d.schedule || [],
90
  pendingChanges: d.pendingChanges || [],
91
  online: !!deviceClients[d.uuid],
 
92
  };
93
  }
94
 
 
95
  function handleAdminConnection(ws) {
96
- adminClients.add(ws);
97
- console.log(`[Admin] connected, total=${adminClients.size}`);
98
- send(ws, { type: 'full_state', ...getFullState() });
 
 
 
 
 
 
99
 
100
  ws.on('message', (raw) => {
101
  let msg;
102
  try { msg = JSON.parse(raw); } catch { return; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  handleAdminMessage(ws, msg);
104
  });
105
 
106
  ws.on('close', () => {
107
  adminClients.delete(ws);
 
108
  console.log(`[Admin] disconnected, total=${adminClients.size}`);
109
  });
110
 
111
  ws.on('error', (e) => console.error('[Admin] WS error', e.message));
 
 
 
112
  }
113
 
114
  function handleAdminMessage(ws, msg) {
@@ -125,19 +208,13 @@ function handleAdminMessage(ws, msg) {
125
  case 'create_notification': {
126
  const id = uuidv4();
127
  const notif = {
128
- id,
129
- name: msg.name,
130
- heading: msg.heading,
131
- body: msg.body,
132
- hyperlink: msg.hyperlink || '',
133
- displayed: false,
134
- soundId: msg.soundId || null,
135
  };
136
  notifications[id] = notif;
137
  for (const uuid of Object.keys(devices)) {
138
- if (!devices[uuid].notifications.find(n => n.id === id)) {
139
  devices[uuid].notifications.push({ ...notif });
140
- }
141
  }
142
  broadcastAdmin({ type: 'notification_added', notification: notif });
143
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
@@ -150,32 +227,21 @@ function handleAdminMessage(ws, msg) {
150
  if (!device) return;
151
  device.schedule = device.schedule || [];
152
  const existing = device.schedule.find(s => s.notificationId === msg.notificationId);
153
- if (existing) {
154
- existing.scheduledAt = msg.scheduledAt;
155
- } else {
156
- device.schedule.push({ notificationId: msg.notificationId, scheduledAt: msg.scheduledAt });
157
- }
158
  const scheduleMsg = { type: 'schedule_update', schedule: device.schedule };
159
- if (deviceClients[msg.uuid]) {
160
- broadcastDevice(msg.uuid, scheduleMsg);
161
- } else {
162
- device.pendingChanges = device.pendingChanges || [];
163
- device.pendingChanges.push(scheduleMsg);
164
- }
165
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
166
  break;
167
  }
168
 
169
  case 'play_now': {
170
  const playMsg = { type: 'play_now', notificationId: msg.notificationId };
171
- if (deviceClients[msg.uuid]) {
172
- broadcastDevice(msg.uuid, playMsg);
173
- } else {
174
  const device = devices[msg.uuid];
175
- if (device) {
176
- device.pendingChanges = device.pendingChanges || [];
177
- device.pendingChanges.push(playMsg);
178
- }
179
  }
180
  break;
181
  }
@@ -185,12 +251,8 @@ function handleAdminMessage(ws, msg) {
185
  if (!device) return;
186
  device.name = msg.name;
187
  const nameMsg = { type: 'name_update', name: msg.name };
188
- if (deviceClients[msg.uuid]) {
189
- broadcastDevice(msg.uuid, nameMsg);
190
- } else {
191
- device.pendingChanges = device.pendingChanges || [];
192
- device.pendingChanges.push(nameMsg);
193
- }
194
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
195
  break;
196
  }
@@ -200,12 +262,8 @@ function handleAdminMessage(ws, msg) {
200
  if (!device) return;
201
  device.schedule = (device.schedule || []).filter(s => s.notificationId !== msg.notificationId);
202
  const rmMsg = { type: 'schedule_update', schedule: device.schedule };
203
- if (deviceClients[msg.uuid]) {
204
- broadcastDevice(msg.uuid, rmMsg);
205
- } else {
206
- device.pendingChanges = device.pendingChanges || [];
207
- device.pendingChanges.push(rmMsg);
208
- }
209
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
210
  break;
211
  }
@@ -221,9 +279,9 @@ function handleAdminMessage(ws, msg) {
221
  }
222
  }
223
 
 
224
  function handleDeviceConnection(ws) {
225
  let deviceUUID = null;
226
- console.log('[Device] new connection (uuid pending)');
227
 
228
  ws.on('message', (raw) => {
229
  let msg;
@@ -238,9 +296,7 @@ function handleDeviceConnection(ws) {
238
  uuid: deviceUUID,
239
  name: `Device ${Object.keys(devices).length + 1}`,
240
  notifications: Object.values(notifications).map(n => ({ ...n })),
241
- lastConnection: null,
242
- schedule: [],
243
- pendingChanges: [],
244
  };
245
  }
246
 
@@ -254,11 +310,8 @@ function handleDeviceConnection(ws) {
254
  }
255
 
256
  send(ws, {
257
- type: 'device_init',
258
- uuid: deviceUUID,
259
- notifications: device.notifications,
260
- schedule: device.schedule,
261
- name: device.name,
262
  });
263
 
264
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
@@ -287,7 +340,6 @@ function handleDeviceMessage(ws, uuid, msg) {
287
  if (!device) return;
288
 
289
  switch (msg.type) {
290
-
291
  case 'mark_displayed': {
292
  const notif = device.notifications.find(n => n.id === msg.notificationId);
293
  if (notif) notif.displayed = true;
@@ -295,30 +347,27 @@ function handleDeviceMessage(ws, uuid, msg) {
295
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
296
  break;
297
  }
298
-
299
  case 'request_sound': {
300
  const sound = sounds[msg.soundId];
301
  if (sound) send(ws, { type: 'sound_data', sound });
302
  break;
303
  }
304
-
305
  case 'sync_schedule': {
306
  device.schedule = msg.schedule || [];
307
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
308
  break;
309
  }
310
-
311
  case 'cached_sounds': {
312
  device.cachedSounds = msg.soundIds || [];
313
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
314
  break;
315
  }
316
-
317
  default:
318
  console.warn('[Device] unknown message type:', msg.type);
319
  }
320
  }
321
 
 
322
  const PORT = process.env.PORT || 7860;
323
  httpServer.listen(PORT, () => {
324
  console.log(`Server running on http://0.0.0.0:${PORT}`);
 
3
  const path = require('path');
4
  const { WebSocketServer, WebSocket } = require('ws');
5
  const { v4: uuidv4 } = require('uuid');
6
+ const crypto = require('crypto');
7
+
8
+ // ─── Auth ─────────────────────────────────────────────────────────────────────
9
+ // Secret key from environment variable "Key"
10
+ const SECRET = process.env.Key || '';
11
+ if (!SECRET) console.warn('[Auth] WARNING: Environment variable "Key" is not set. Admin login will be disabled.');
12
+
13
+ // HMAC-based session tokens: token = hmac(SECRET, nonce+timestamp)
14
+ // Sessions are stored server-side; possession of token alone is not enough β€”
15
+ // the server must have issued it.
16
+ const activeSessions = new Set(); // Set<token>
17
+
18
+ function createSession() {
19
+ const nonce = crypto.randomBytes(32).toString('hex');
20
+ const token = crypto.createHmac('sha256', SECRET).update(nonce).digest('hex');
21
+ activeSessions.add(token);
22
+ return token;
23
+ }
24
+
25
+ function isValidSession(token) {
26
+ return typeof token === 'string' && activeSessions.has(token);
27
+ }
28
 
29
+ // ─── In-Memory State ──────────────────────────────────────────────────────────
30
  const sounds = {};
31
  const notifications = {};
32
  const devices = {};
33
 
34
  const deviceClients = {};
35
+ const adminClients = new Set(); // only authenticated WS connections
36
 
37
  const MIME = {
38
  '.html': 'text/html',
 
43
  '.ico': 'image/x-icon',
44
  };
45
 
46
+ // ─── HTTP File Server ─────────────────────────────────────────────────────────
47
  const httpServer = http.createServer((req, res) => {
48
  let urlPath = req.url.split('?')[0];
49
  if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
50
  if (urlPath === '/admin' || urlPath === '/admin/') urlPath = '/admin/index.html';
51
 
52
  const filePath = path.join(__dirname, 'public', urlPath);
53
+
54
+ // Prevent path traversal
55
+ const publicDir = path.resolve(__dirname, 'public');
56
+ const resolved = path.resolve(filePath);
57
+ if (!resolved.startsWith(publicDir)) {
58
+ res.writeHead(403); res.end('Forbidden'); return;
59
+ }
60
+
61
  const ext = path.extname(filePath);
62
  const mime = MIME[ext] || 'application/octet-stream';
63
 
64
  fs.readFile(filePath, (err, data) => {
65
+ if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); return; }
 
 
 
 
66
  res.writeHead(200, { 'Content-Type': mime });
67
  res.end(data);
68
  });
69
  });
70
 
71
+ // ─── WebSocket Server ─────────────────────────────────────────────────────────
72
  const wss = new WebSocketServer({ server: httpServer });
73
 
74
  wss.on('connection', (ws, req) => {
75
  const urlPath = req.url || '/';
76
+ if (urlPath.startsWith('/admin-ws')) {
 
77
  handleAdminConnection(ws);
78
  } else {
79
  handleDeviceConnection(ws);
80
  }
81
  });
82
 
83
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
84
  function send(ws, msg) {
85
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
 
 
86
  }
87
 
88
  function broadcastAdmin(msg) {
 
115
  schedule: d.schedule || [],
116
  pendingChanges: d.pendingChanges || [],
117
  online: !!deviceClients[d.uuid],
118
+ cachedSounds: d.cachedSounds || [],
119
  };
120
  }
121
 
122
+ // ─── Admin Connection Handler ─────────────────────────────────────────────────
123
  function handleAdminConnection(ws) {
124
+ let authenticated = false;
125
+
126
+ // Give them 10 seconds to authenticate, then close
127
+ const authTimeout = setTimeout(() => {
128
+ if (!authenticated) {
129
+ send(ws, { type: 'auth_timeout' });
130
+ ws.close();
131
+ }
132
+ }, 10000);
133
 
134
  ws.on('message', (raw) => {
135
  let msg;
136
  try { msg = JSON.parse(raw); } catch { return; }
137
+
138
+ // ── Authentication handshake ──────────────────────────────────────────────
139
+ if (!authenticated) {
140
+ if (msg.type === 'auth_resume' && isValidSession(msg.token)) {
141
+ // Client is resuming a valid session
142
+ authenticated = true;
143
+ clearTimeout(authTimeout);
144
+ adminClients.add(ws);
145
+ send(ws, { type: 'auth_ok', token: msg.token });
146
+ send(ws, { type: 'full_state', ...getFullState() });
147
+ console.log('[Admin] session resumed');
148
+ return;
149
+ }
150
+
151
+ if (msg.type === 'auth_login') {
152
+ if (!SECRET) {
153
+ send(ws, { type: 'auth_error', reason: 'Server has no Key configured.' });
154
+ return;
155
+ }
156
+ // Constant-time comparison to prevent timing attacks
157
+ const provided = Buffer.from(String(msg.password || ''));
158
+ const expected = Buffer.from(SECRET);
159
+ const match = provided.length === expected.length &&
160
+ crypto.timingSafeEqual(provided, expected);
161
+ if (match) {
162
+ authenticated = true;
163
+ clearTimeout(authTimeout);
164
+ const token = createSession();
165
+ adminClients.add(ws);
166
+ send(ws, { type: 'auth_ok', token });
167
+ send(ws, { type: 'full_state', ...getFullState() });
168
+ console.log('[Admin] authenticated');
169
+ } else {
170
+ send(ws, { type: 'auth_error', reason: 'Invalid password.' });
171
+ console.warn('[Admin] failed login attempt');
172
+ }
173
+ return;
174
+ }
175
+
176
+ // Any other message before auth: reject silently
177
+ send(ws, { type: 'auth_required' });
178
+ return;
179
+ }
180
+
181
+ // ── Authenticated messages ─────────────────────────────────────────────────
182
  handleAdminMessage(ws, msg);
183
  });
184
 
185
  ws.on('close', () => {
186
  adminClients.delete(ws);
187
+ clearTimeout(authTimeout);
188
  console.log(`[Admin] disconnected, total=${adminClients.size}`);
189
  });
190
 
191
  ws.on('error', (e) => console.error('[Admin] WS error', e.message));
192
+
193
+ // Prompt client to authenticate
194
+ send(ws, { type: 'auth_required' });
195
  }
196
 
197
  function handleAdminMessage(ws, msg) {
 
208
  case 'create_notification': {
209
  const id = uuidv4();
210
  const notif = {
211
+ id, name: msg.name, heading: msg.heading, body: msg.body,
212
+ hyperlink: msg.hyperlink || '', displayed: false, soundId: msg.soundId || null,
 
 
 
 
 
213
  };
214
  notifications[id] = notif;
215
  for (const uuid of Object.keys(devices)) {
216
+ if (!devices[uuid].notifications.find(n => n.id === id))
217
  devices[uuid].notifications.push({ ...notif });
 
218
  }
219
  broadcastAdmin({ type: 'notification_added', notification: notif });
220
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
 
227
  if (!device) return;
228
  device.schedule = device.schedule || [];
229
  const existing = device.schedule.find(s => s.notificationId === msg.notificationId);
230
+ if (existing) { existing.scheduledAt = msg.scheduledAt; }
231
+ else { device.schedule.push({ notificationId: msg.notificationId, scheduledAt: msg.scheduledAt }); }
 
 
 
232
  const scheduleMsg = { type: 'schedule_update', schedule: device.schedule };
233
+ if (deviceClients[msg.uuid]) { broadcastDevice(msg.uuid, scheduleMsg); }
234
+ else { (device.pendingChanges = device.pendingChanges || []).push(scheduleMsg); }
 
 
 
 
235
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
236
  break;
237
  }
238
 
239
  case 'play_now': {
240
  const playMsg = { type: 'play_now', notificationId: msg.notificationId };
241
+ if (deviceClients[msg.uuid]) { broadcastDevice(msg.uuid, playMsg); }
242
+ else {
 
243
  const device = devices[msg.uuid];
244
+ if (device) (device.pendingChanges = device.pendingChanges || []).push(playMsg);
 
 
 
245
  }
246
  break;
247
  }
 
251
  if (!device) return;
252
  device.name = msg.name;
253
  const nameMsg = { type: 'name_update', name: msg.name };
254
+ if (deviceClients[msg.uuid]) { broadcastDevice(msg.uuid, nameMsg); }
255
+ else { (device.pendingChanges = device.pendingChanges || []).push(nameMsg); }
 
 
 
 
256
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
257
  break;
258
  }
 
262
  if (!device) return;
263
  device.schedule = (device.schedule || []).filter(s => s.notificationId !== msg.notificationId);
264
  const rmMsg = { type: 'schedule_update', schedule: device.schedule };
265
+ if (deviceClients[msg.uuid]) { broadcastDevice(msg.uuid, rmMsg); }
266
+ else { (device.pendingChanges = device.pendingChanges || []).push(rmMsg); }
 
 
 
 
267
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
268
  break;
269
  }
 
279
  }
280
  }
281
 
282
+ // ─── Device Connection Handler ────────────────────────────────────────────────
283
  function handleDeviceConnection(ws) {
284
  let deviceUUID = null;
 
285
 
286
  ws.on('message', (raw) => {
287
  let msg;
 
296
  uuid: deviceUUID,
297
  name: `Device ${Object.keys(devices).length + 1}`,
298
  notifications: Object.values(notifications).map(n => ({ ...n })),
299
+ lastConnection: null, schedule: [], pendingChanges: [],
 
 
300
  };
301
  }
302
 
 
310
  }
311
 
312
  send(ws, {
313
+ type: 'device_init', uuid: deviceUUID,
314
+ notifications: device.notifications, schedule: device.schedule, name: device.name,
 
 
 
315
  });
316
 
317
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
 
340
  if (!device) return;
341
 
342
  switch (msg.type) {
 
343
  case 'mark_displayed': {
344
  const notif = device.notifications.find(n => n.id === msg.notificationId);
345
  if (notif) notif.displayed = true;
 
347
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
348
  break;
349
  }
 
350
  case 'request_sound': {
351
  const sound = sounds[msg.soundId];
352
  if (sound) send(ws, { type: 'sound_data', sound });
353
  break;
354
  }
 
355
  case 'sync_schedule': {
356
  device.schedule = msg.schedule || [];
357
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
358
  break;
359
  }
 
360
  case 'cached_sounds': {
361
  device.cachedSounds = msg.soundIds || [];
362
  broadcastAdmin({ type: 'devices_updated', devices: Object.values(devices).map(devicePublic) });
363
  break;
364
  }
 
365
  default:
366
  console.warn('[Device] unknown message type:', msg.type);
367
  }
368
  }
369
 
370
+ // ─── Start ────────────────────────────────────────────────────────────────────
371
  const PORT = process.env.PORT || 7860;
372
  httpServer.listen(PORT, () => {
373
  console.log(`Server running on http://0.0.0.0:${PORT}`);