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

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