@soundworks/core 4.0.0-alpha.2 → 4.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,17 +5,19 @@ import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { X509Certificate, createPrivateKey } from 'node:crypto';
7
7
 
8
+ import { isPlainObject, idGenerator, getTime } from '@ircam/sc-utils';
8
9
  import chalk from 'chalk';
9
10
  import compression from 'compression';
10
11
  import express from 'express';
11
- import isPlainObject from 'is-plain-obj';
12
+ import equal from 'fast-deep-equal';
12
13
  import Keyv from 'keyv';
13
14
  import { KeyvFile } from 'keyv-file';
14
- import merge from 'lodash.merge';
15
+ import merge from 'lodash/merge.js';
15
16
  import pem from 'pem';
16
17
  import compile from 'template-literal';
17
18
 
18
19
  import auditSchema from './audit-schema.js';
20
+ import { encryptData, decryptData } from './crypto.js';
19
21
  import Client from './Client.js';
20
22
  import ContextManager from './ContextManager.js';
21
23
  import PluginManager from './PluginManager.js';
@@ -23,12 +25,14 @@ import StateManager from './StateManager.js';
23
25
  import Sockets from './Sockets.js';
24
26
  import logger from '../common/logger.js';
25
27
  import {
28
+ SERVER_ID,
26
29
  CLIENT_HANDSHAKE_REQUEST,
27
30
  CLIENT_HANDSHAKE_RESPONSE,
28
31
  CLIENT_HANDSHAKE_ERROR,
29
32
  AUDIT_STATE_NAME,
30
33
  } from '../common/constants.js';
31
34
 
35
+
32
36
  let _dbNamespaces = new Set();
33
37
 
34
38
  /**
@@ -73,6 +77,8 @@ const DEFAULT_CONFIG = {
73
77
  },
74
78
  };
75
79
 
80
+ const TOKEN_VALID_DURATION = 20; // sec
81
+
76
82
  // set terminal title
77
83
  /** @private */
78
84
  function setTerminalTitle(server) {
@@ -156,8 +162,8 @@ class Server {
156
162
  throw new Error(`[soundworks:Server] Invalid argument for Server constructor, config should be an object`);
157
163
  }
158
164
  /**
159
- * Given config object merged with the following defaults:
160
- * ```
165
+ * @description Given config object merged with the following defaults:
166
+ * @example
161
167
  * {
162
168
  * env: {
163
169
  * type: 'development',
@@ -178,7 +184,6 @@ class Server {
178
184
  * clients: {},
179
185
  * }
180
186
  * }
181
- * ```
182
187
  */
183
188
  this.config = merge({}, DEFAULT_CONFIG, config);
184
189
 
@@ -210,11 +215,11 @@ class Server {
210
215
  throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, should contain both "cert" and "key" entries`);
211
216
  }
212
217
  // @todo - move that to constructor
213
- if (!fs.existsSync(httpsInfos.cert)) {
218
+ if (httpsInfos.cert !== null && !fs.existsSync(httpsInfos.cert)) {
214
219
  throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, "cert" file not found`);
215
220
  }
216
221
 
217
- if (!fs.existsSync(httpsInfos.key)) {
222
+ if (httpsInfos.key !== null && !fs.existsSync(httpsInfos.key)) {
218
223
  throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, "key" file not found`);
219
224
  }
220
225
  }
@@ -236,8 +241,10 @@ class Server {
236
241
  * // expose assets located in the `soundfiles` directory on the network
237
242
  * server.router.use('/soundfiles', express.static('soundfiles')));
238
243
  */
244
+ // @note: we use express() instead of express.Router() because all 404 and
245
+ // error stuff is handled by default
239
246
  this.router = express();
240
- // compression (must be set before serve-static)
247
+ // compression (must be set before express.static())
241
248
  this.router.use(compression());
242
249
 
243
250
  /**
@@ -312,7 +319,15 @@ class Server {
312
319
  /** @private */
313
320
  this._onStatusChangeCallbacks = new Set();
314
321
  /** @private */
322
+ this._onClientConnectCallbacks = new Set();
323
+ /** @private */
324
+ this._onClientDisconnectCallbacks = new Set();
325
+ /** @private */
315
326
  this._auditState = null;
327
+ /** @private */
328
+ this._pendingConnectionTokens = new Set();
329
+ /** @private */
330
+ this._trustedClients = new Set();
316
331
 
317
332
  // register audit state schema
318
333
  this.stateManager.registerSchema(AUDIT_STATE_NAME, auditSchema);
@@ -321,6 +336,15 @@ class Server {
321
336
  setTerminalTitle(this);
322
337
  }
323
338
 
339
+ /**
340
+ * Id of the server, a constant set to -1
341
+ * @type {Number}
342
+ * @readonly
343
+ */
344
+ get id() {
345
+ return SERVER_ID;
346
+ }
347
+
324
348
  /**
325
349
  * The `init` method is part of the initialization lifecycle of the `soundworks`
326
350
  * server. Most of the time, the `init` method will be implicitly called by the
@@ -357,11 +381,24 @@ class Server {
357
381
 
358
382
  // basic http authentication
359
383
  if (this.config.env.auth) {
360
- this.router.use((req, res, next) => {
384
+ const ids = idGenerator();
361
385
 
362
- const isProtected = this.config.env.auth.clients
363
- .map(type => req.path.endsWith(`/${type}`))
364
- .reduce((acc, value) => acc || value, false);
386
+ const soundworksAuth = (req, res, next) => {
387
+ let role = null;
388
+
389
+ for (let [_role, config] of Object.entries(this.config.app.clients)) {
390
+ if (req.path === config.route) {
391
+ role = _role;
392
+ }
393
+ }
394
+
395
+ // route that are not client entry points just pass through the middleware
396
+ if (role === null) {
397
+ next();
398
+ return;
399
+ }
400
+
401
+ const isProtected = this.isProtected(role);
365
402
 
366
403
  if (isProtected) {
367
404
  // authentication middleware
@@ -373,35 +410,57 @@ class Server {
373
410
  // verify login and password are set and correct
374
411
  if (login && password && login === auth.login && password === auth.password) {
375
412
  // -> access granted...
413
+ // generate token for web socket to check connections
414
+ const id = ids.next().value;
415
+ const ip = req.ip;
416
+ const time = getTime();
417
+ const token = { id, ip, time };
418
+ const encryptedToken = encryptData(token);
419
+
420
+ this._pendingConnectionTokens.add(encryptedToken);
421
+
422
+ setTimeout(() => {
423
+ this._pendingConnectionTokens.delete(encryptedToken);
424
+ }, TOKEN_VALID_DURATION * 1000);
425
+
426
+ // pass to the response object to be send to the client
427
+ res.swToken = encryptedToken;
428
+
376
429
  return next();
377
430
  }
378
431
 
379
- // -> access denied...
432
+ // show login / password modal
380
433
  res.writeHead(401, {
381
434
  'WWW-Authenticate':'Basic',
382
435
  'Content-Type':'text/plain',
383
436
  });
437
+
384
438
  res.end('Authentication required.');
385
439
  } else {
386
- // route not protected
440
+ // route is not protected
387
441
  return next();
388
442
  }
389
- });
390
- }
443
+ };
391
444
 
392
- // start http server
393
- const useHttps = this.config.env.useHttps || false;
445
+ this.router.use(soundworksAuth);
446
+ }
394
447
 
395
448
  // ------------------------------------------------------------
396
449
  // create HTTP(S) SERVER
397
450
  // ------------------------------------------------------------
451
+ const useHttps = this.config.env.useHttps || false;
452
+
398
453
  if (!useHttps) {
399
454
  this.httpServer = http.createServer(this.router);
400
455
  } else {
401
456
  const httpsInfos = this.config.env.httpsInfos;
457
+ let useSelfSigned = false;
402
458
 
403
- // if certs have been given in config
404
- if (httpsInfos !== null) {
459
+ if (!httpsInfos || equal(httpsInfos, { cert: null, key: null })) {
460
+ useSelfSigned = true;
461
+ }
462
+
463
+ if (!useSelfSigned) {
405
464
  try {
406
465
  // existance of file is checked in contructor
407
466
  let cert = fs.readFileSync(httpsInfos.cert);
@@ -411,7 +470,7 @@ class Server {
411
470
  // this fails with self-signed certificates for whatever reason...
412
471
  try {
413
472
  x509 = new X509Certificate(cert);
414
- } catch(err) {
473
+ } catch (err) {
415
474
  this._dispatchStatus('errored');
416
475
  throw new Error(`[soundworks:Server] Invalid https cert file`);
417
476
  }
@@ -423,7 +482,7 @@ class Server {
423
482
  this._dispatchStatus('errored');
424
483
  throw new Error(`[soundworks:Server] Invalid https key file`);
425
484
  }
426
- } catch(err) {
485
+ } catch (err) {
427
486
  this._dispatchStatus('errored');
428
487
  throw new Error(`[soundworks:Server] Invalid https key file`);
429
488
  }
@@ -447,7 +506,7 @@ class Server {
447
506
  };
448
507
 
449
508
  this.httpServer = https.createServer({ key, cert }, this.router);
450
- } catch(err) {
509
+ } catch (err) {
451
510
  logger.error(`
452
511
  Invalid certificate files, please check your:
453
512
  - key file: ${httpsInfos.key}
@@ -591,7 +650,7 @@ Invalid certificate files, please check your:
591
650
  await this.sockets.start(
592
651
  this,
593
652
  this.config.env.websockets,
594
- (role, socket) => this._onSocketConnection(role, socket),
653
+ (...args) => this._onSocketConnection(...args),
595
654
  );
596
655
 
597
656
  // ------------------------------------------------------------
@@ -648,6 +707,10 @@ Invalid certificate files, please check your:
648
707
 
649
708
  await this._dispatchStatus('started');
650
709
 
710
+ if (this.config.env.type === 'development') {
711
+ logger.log(`\n> press "${chalk.bold('Ctrl + C')}" to exit`);
712
+ }
713
+
651
714
  resolve();
652
715
  });
653
716
  });
@@ -706,6 +769,8 @@ Invalid certificate files, please check your:
706
769
  route += `${role}`;
707
770
  }
708
771
 
772
+ this.config.app.clients[role].route = route;
773
+
709
774
  // define template filename: `${role}.html` or `default.html`
710
775
  const {
711
776
  templatePath,
@@ -722,7 +787,7 @@ Invalid certificate files, please check your:
722
787
  try {
723
788
  const stats = fs.statSync(clientTmpl);
724
789
  template = stats.isFile() ? clientTmpl : defaultTmpl;
725
- } catch(err) {
790
+ } catch (err) {
726
791
  template = defaultTmpl;
727
792
  }
728
793
 
@@ -730,15 +795,21 @@ Invalid certificate files, please check your:
730
795
 
731
796
  try {
732
797
  tmplString = fs.readFileSync(template, 'utf8');
733
- } catch(err) {
798
+ } catch (err) {
734
799
  throw new Error(`[soundworks:Server] html template file "${template}" not found`);
735
800
  }
736
801
 
737
802
  const tmpl = templateEngine.compile(tmplString);
738
- // http request
739
- router.get(route, (req, res) => {
803
+
804
+ const soundworksClientHandler = (req, res) => {
740
805
  const data = clientConfigFunction(role, this.config, req);
741
806
 
807
+ // if the client has gone through the connection middleware (add succedeed),
808
+ // add the token to the data object
809
+ if (res.swToken) {
810
+ data.token = res.swToken;
811
+ }
812
+
742
813
  // CORS / COOP / COEP headers for `crossOriginIsolated pages,
743
814
  // enables `sharedArrayBuffers` and high precision timers
744
815
  // cf. https://web.dev/why-coop-coep/
@@ -752,34 +823,78 @@ Invalid certificate files, please check your:
752
823
 
753
824
  const appIndex = tmpl(data);
754
825
  res.end(appIndex);
755
- });
826
+ };
827
+
828
+ // http request
829
+ router.get(route, soundworksClientHandler);
756
830
 
757
831
  // return route infos for logging on server start
758
832
  return route;
759
833
  }
760
834
 
835
+ onClientConnect(callback) {
836
+ this._onClientConnectCallbacks.add(callback);
837
+
838
+ return () => this._onClientConnectCallbacks.delete(callback);
839
+ }
840
+
841
+ onClientDisconnect(callback) {
842
+ this._onClientDisconnectCallbacks.add(callback);
843
+
844
+ return () => this._onClientDisconnectCallbacks.delete(callback);
845
+ }
846
+
761
847
  /**
762
848
  * Socket connection callback.
763
849
  * @private
764
850
  */
765
- _onSocketConnection(role, socket) {
851
+ _onSocketConnection(role, socket, connectionToken) {
766
852
  const client = new Client(role, socket);
767
853
  const roles = Object.keys(this.config.app.clients);
768
854
 
855
+ // this has been validated
856
+ if (this.isProtected(role) && this.isValidConnectionToken(connectionToken)) {
857
+ const { ip } = decryptData(connectionToken);
858
+ const newData = {
859
+ ip: ip,
860
+ id: client.id,
861
+ };
862
+
863
+ const newToken = encryptData(newData);
864
+
865
+ client.token = newToken;
866
+
867
+ this._pendingConnectionTokens.delete(connectionToken);
868
+ this._trustedClients.add(client);
869
+ }
870
+
769
871
  socket.addListener('close', async () => {
770
- // do nothin if client type was invalid
872
+ // do nothing if client role is invalid
771
873
  if (roles.includes(role)) {
772
874
  // decrement audit state counter
773
875
  const numClients = this._auditState.get('numClients');
774
876
  numClients[role] -= 1;
775
877
  this._auditState.set({ numClients });
776
878
 
777
- // clean context manager, await before cleaning state manager
778
- await this.contextManager.removeClient(client);
779
- // remove client from pluginManager
780
- await this.pluginManager.removeClient(client);
781
- // clean state manager
782
- await this.stateManager.removeClient(client.id);
879
+ // delete token
880
+ if (this._trustedClients.has(client)) {
881
+ this._trustedClients.delete(client);
882
+ }
883
+
884
+ // if something goes wrong here, the 'close' event is called again and
885
+ // again and again... let's just log the error and terminate the socket
886
+ try {
887
+ // clean context manager, await before cleaning state manager
888
+ await this.contextManager.removeClient(client);
889
+ // remove client from pluginManager
890
+ await this.pluginManager.removeClient(client);
891
+ // clean state manager
892
+ await this.stateManager.removeClient(client.id);
893
+
894
+ this._onClientDisconnectCallbacks.forEach(callback => callback(client));
895
+ } catch (err) {
896
+ console.error(err);
897
+ }
783
898
  }
784
899
 
785
900
  // clean sockets
@@ -801,7 +916,7 @@ Invalid certificate files, please check your:
801
916
 
802
917
  try {
803
918
  this.pluginManager.checkRegisteredPlugins(registeredPlugins);
804
- } catch(err) {
919
+ } catch (err) {
805
920
  socket.send(CLIENT_HANDSHAKE_ERROR, {
806
921
  type: 'invalid-plugin-list',
807
922
  message: err.message,
@@ -826,8 +941,10 @@ Invalid certificate files, please check your:
826
941
  // add client to context manager
827
942
  await this.contextManager.addClient(client);
828
943
 
829
- const { id, uuid } = client;
830
- socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid });
944
+ this._onClientConnectCallbacks.forEach(callback => callback(client));
945
+
946
+ const { id, uuid, token } = client;
947
+ socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token });
831
948
  });
832
949
  }
833
950
 
@@ -904,6 +1021,27 @@ Invalid certificate files, please check your:
904
1021
  * you should rely on these defaults._
905
1022
  */
906
1023
  useDefaultApplicationTemplate() {
1024
+ const buildDirectory = path.join('.build', 'public');
1025
+
1026
+ const useMinifiedFile = {};
1027
+ const roles = Object.keys(this.config.app.clients);
1028
+
1029
+ roles.forEach(role => {
1030
+ if (this.config.env.type === 'production') {
1031
+ // check if minified file exists
1032
+ const minifiedFilePath = path.join(buildDirectory, `${role}.min.js`);
1033
+
1034
+ if (fs.existsSync(minifiedFilePath)) {
1035
+ useMinifiedFile[role] = true;
1036
+ } else {
1037
+ console.log(chalk.yellow(` > Minified file not found for client "${role}", falling back to normal build file (use \`npm run build:production && npm start\` to use minified files)`));
1038
+ useMinifiedFile[role] = false;
1039
+ }
1040
+ } else {
1041
+ useMinifiedFile[role] = false;
1042
+ }
1043
+ });
1044
+
907
1045
  this._applicationTemplateOptions = {
908
1046
  templateEngine: { compile },
909
1047
  templatePath: path.join('.build', 'server', 'tmpl'),
@@ -918,13 +1056,14 @@ Invalid certificate files, please check your:
918
1056
  type: config.env.type,
919
1057
  websockets: config.env.websockets,
920
1058
  subpath: config.env.subpath,
1059
+ useMinifiedFile: useMinifiedFile[role],
921
1060
  },
922
1061
  };
923
1062
  },
924
1063
  };
925
1064
 
926
1065
  this.router.use(express.static('public'));
927
- this.router.use('/build', express.static(path.join('.build', 'public')));
1066
+ this.router.use('/build', express.static(buildDirectory));
928
1067
  }
929
1068
 
930
1069
  /**
@@ -960,6 +1099,81 @@ Invalid certificate files, please check your:
960
1099
 
961
1100
  return this._auditState;
962
1101
  }
1102
+
1103
+ /** @private */
1104
+ isProtected(role) {
1105
+ if (this.config.env.auth && Array.isArray(this.config.env.auth.clients)) {
1106
+ return this.config.env.auth.clients.includes(role);
1107
+ }
1108
+
1109
+ return false;
1110
+ }
1111
+
1112
+ /** @private */
1113
+ isValidConnectionToken(token) {
1114
+ // token should be in pending token list
1115
+ if (!this._pendingConnectionTokens.has(token)) {
1116
+ return false;
1117
+ }
1118
+
1119
+ // check the token is not too old
1120
+ const data = decryptData(token);
1121
+ const now = getTime();
1122
+
1123
+ // token is valid only for 30 seconds (this is arbitrary)
1124
+ if (now > data.time + TOKEN_VALID_DURATION) {
1125
+ // delete the token, is too old
1126
+ this._pendingConnectionTokens.delete(token);
1127
+ return false;
1128
+ } else {
1129
+ return true;
1130
+ }
1131
+ }
1132
+
1133
+ /**
1134
+ * Check if the given client is trusted, i.e. config.env.type == 'production'
1135
+ * and the client is protected behind a password.
1136
+ *
1137
+ * @param {server.Client} client - Client to be tested
1138
+ * @returns {Boolean}
1139
+ */
1140
+ isTrustedClient(client) {
1141
+ if (this.config.env.type !== 'production') {
1142
+ return true;
1143
+ } else {
1144
+ return this._trustedClients.has(client);
1145
+ }
1146
+ }
1147
+
1148
+ /**
1149
+ * Check if the token from a client is trusted, i.e. config.env.type == 'production'
1150
+ * and the client is protected behind a password.
1151
+ *
1152
+ * @param {Number} clientId - Id of the client
1153
+ * @param {Number} clientIp - Ip of the client
1154
+ * @param {String} token - Token to be tested
1155
+ * @returns {Boolean}
1156
+ */
1157
+ // for stateless interactions, e.g. POST files
1158
+ isTrustedToken(clientId, clientIp, token) {
1159
+ if (this.config.env.type !== 'production') {
1160
+ return true;
1161
+ } else {
1162
+ for (let client of this._trustedClients) {
1163
+ if (client.id === clientId && client.token === token) {
1164
+ // check that given token is consistent with client ip and id
1165
+ const { id, ip } = decryptData(client.token);
1166
+
1167
+ if (clientId === id && clientIp === ip) {
1168
+ return true;
1169
+ }
1170
+ }
1171
+
1172
+ }
1173
+
1174
+ return false;
1175
+ }
1176
+ }
963
1177
  }
964
1178
 
965
1179
  export default Server;
@@ -18,7 +18,6 @@ import BaseSharedState from '../common/BaseSharedState.js';
18
18
  * @callback server.SharedState~deleteOnUpdateCallback
19
19
  */
20
20
 
21
-
22
21
  /**
23
22
  * The `SharedState` is one of the most important and versatile abstraction provided
24
23
  * by `soundworks`. It represents a set of parameters that are synchronized accross
@@ -0,0 +1,39 @@
1
+ import BaseSharedStateCollection from '../common/BaseSharedStateCollection.js';
2
+
3
+ // this file exists only for documentation purposesSharedStateCollection.js
4
+
5
+ /**
6
+ * @callback server.SharedStateCollection~onUpdateCallback
7
+ * @param {server.SharedState} state - State that triggered the update.
8
+ * @param {Object} newValues - Key / value pairs of the updates that have been
9
+ * applied to the state.
10
+ * @param {Object} oldValues - Key / value pairs of the updated params before
11
+ * the updates has been applied to the state.
12
+ * @param {Mixed} [context=null] - Optionnal context object that has been passed
13
+ * with the values updates in the `set` call.
14
+ */
15
+
16
+ /**
17
+ * The `SharedStateCollection` interface represent a collection of all states
18
+ * created from a given schema name on the network.
19
+ *
20
+ * It can optionnaly exclude the states created by the current node.
21
+ *
22
+ * See {@link server.StateManager#getCollection} for factory method API
23
+ *
24
+ * ```
25
+ * const collection = await server.stateManager.getCollection('my-schema');
26
+ * const allValues = collection.getValues();
27
+ * collection.onUpdate((state, newValues, oldValues, context) => {
28
+ * // do something
29
+ * });
30
+ * ```
31
+ * @memberof server
32
+ * @extends BaseSharedStateCollection
33
+ * @inheritdoc
34
+ * @hideconstructor
35
+ */
36
+ class SharedStateCollection extends BaseSharedStateCollection {}
37
+
38
+ export default SharedStateCollection;
39
+