@soundworks/core 4.0.0-alpha.4 → 4.0.0-alpha.6
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.
- package/.github/workflows/gh-pages.yml +31 -0
- package/.github/workflows/unit-tests.yml +18 -0
- package/README.md +2 -2
- package/package.json +6 -7
- package/src/client/Client.js +11 -5
- package/src/client/Context.js +2 -2
- package/src/client/ContextManager.js +1 -1
- package/src/client/SharedStateCollection.js +35 -0
- package/src/client/Socket.js +17 -9
- package/src/common/BasePluginManager.js +4 -5
- package/src/common/BaseSharedState.js +59 -15
- package/src/common/BaseSharedStateCollection.js +179 -0
- package/src/common/BaseStateManager.js +70 -16
- package/src/common/ParameterBag.js +63 -16
- package/src/common/promise-store.js +1 -1
- package/src/server/Client.js +7 -2
- package/src/server/ContextManager.js +4 -4
- package/src/server/Server.js +197 -27
- package/src/server/SharedState.js +0 -1
- package/src/server/SharedStateCollection.js +36 -0
- package/src/server/Sockets.js +25 -4
- package/src/server/StateManager.js +7 -5
- package/src/server/crypto.js +38 -0
- package/src/common/utils.js +0 -28
package/src/server/Server.js
CHANGED
|
@@ -5,10 +5,11 @@ import path from 'node:path';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { X509Certificate, createPrivateKey } from 'node:crypto';
|
|
7
7
|
|
|
8
|
+
import { getTime } from '@ircam/sc-gettime';
|
|
9
|
+
import { isPlainObject, idGenerator } 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';
|
|
12
13
|
import Keyv from 'keyv';
|
|
13
14
|
import { KeyvFile } from 'keyv-file';
|
|
14
15
|
import merge from 'lodash.merge';
|
|
@@ -16,6 +17,7 @@ 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';
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
AUDIT_STATE_NAME,
|
|
30
32
|
} from '../common/constants.js';
|
|
31
33
|
|
|
34
|
+
|
|
32
35
|
let _dbNamespaces = new Set();
|
|
33
36
|
|
|
34
37
|
/**
|
|
@@ -73,6 +76,8 @@ const DEFAULT_CONFIG = {
|
|
|
73
76
|
},
|
|
74
77
|
};
|
|
75
78
|
|
|
79
|
+
const TOKEN_VALID_DURATION = 20; // sec
|
|
80
|
+
|
|
76
81
|
// set terminal title
|
|
77
82
|
/** @private */
|
|
78
83
|
function setTerminalTitle(server) {
|
|
@@ -156,8 +161,8 @@ class Server {
|
|
|
156
161
|
throw new Error(`[soundworks:Server] Invalid argument for Server constructor, config should be an object`);
|
|
157
162
|
}
|
|
158
163
|
/**
|
|
159
|
-
* Given config object merged with the following defaults:
|
|
160
|
-
*
|
|
164
|
+
* @description Given config object merged with the following defaults:
|
|
165
|
+
* @example
|
|
161
166
|
* {
|
|
162
167
|
* env: {
|
|
163
168
|
* type: 'development',
|
|
@@ -178,7 +183,6 @@ class Server {
|
|
|
178
183
|
* clients: {},
|
|
179
184
|
* }
|
|
180
185
|
* }
|
|
181
|
-
* ```
|
|
182
186
|
*/
|
|
183
187
|
this.config = merge({}, DEFAULT_CONFIG, config);
|
|
184
188
|
|
|
@@ -236,8 +240,10 @@ class Server {
|
|
|
236
240
|
* // expose assets located in the `soundfiles` directory on the network
|
|
237
241
|
* server.router.use('/soundfiles', express.static('soundfiles')));
|
|
238
242
|
*/
|
|
243
|
+
// @note: we use express() instead of express.Router() because all 404 and
|
|
244
|
+
// error stuff is handled by default
|
|
239
245
|
this.router = express();
|
|
240
|
-
// compression (must be set before
|
|
246
|
+
// compression (must be set before express.static())
|
|
241
247
|
this.router.use(compression());
|
|
242
248
|
|
|
243
249
|
/**
|
|
@@ -313,6 +319,10 @@ class Server {
|
|
|
313
319
|
this._onStatusChangeCallbacks = new Set();
|
|
314
320
|
/** @private */
|
|
315
321
|
this._auditState = null;
|
|
322
|
+
/** @private */
|
|
323
|
+
this._pendingConnectionTokens = new Set();
|
|
324
|
+
/** @private */
|
|
325
|
+
this._trustedClients = new Set();
|
|
316
326
|
|
|
317
327
|
// register audit state schema
|
|
318
328
|
this.stateManager.registerSchema(AUDIT_STATE_NAME, auditSchema);
|
|
@@ -357,11 +367,24 @@ class Server {
|
|
|
357
367
|
|
|
358
368
|
// basic http authentication
|
|
359
369
|
if (this.config.env.auth) {
|
|
360
|
-
|
|
370
|
+
const ids = idGenerator();
|
|
361
371
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
372
|
+
const soundworksAuth = (req, res, next) => {
|
|
373
|
+
let role = null;
|
|
374
|
+
|
|
375
|
+
for (let [_role, config] of Object.entries(this.config.app.clients)) {
|
|
376
|
+
if (req.path === config.route) {
|
|
377
|
+
role = _role;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// route that are not client entry points just pass through the middleware
|
|
382
|
+
if (role === null) {
|
|
383
|
+
next();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const isProtected = this.isProtected(role);
|
|
365
388
|
|
|
366
389
|
if (isProtected) {
|
|
367
390
|
// authentication middleware
|
|
@@ -373,20 +396,39 @@ class Server {
|
|
|
373
396
|
// verify login and password are set and correct
|
|
374
397
|
if (login && password && login === auth.login && password === auth.password) {
|
|
375
398
|
// -> access granted...
|
|
399
|
+
// generate token for web socket to check connections
|
|
400
|
+
const id = ids.next().value;
|
|
401
|
+
const ip = req.ip;
|
|
402
|
+
const time = getTime();
|
|
403
|
+
const token = { id, ip, time };
|
|
404
|
+
const encryptedToken = encryptData(token);
|
|
405
|
+
|
|
406
|
+
this._pendingConnectionTokens.add(encryptedToken);
|
|
407
|
+
|
|
408
|
+
setTimeout(() => {
|
|
409
|
+
this._pendingConnectionTokens.delete(encryptedToken);
|
|
410
|
+
}, TOKEN_VALID_DURATION * 1000);
|
|
411
|
+
|
|
412
|
+
// pass to the response object to be send to the client
|
|
413
|
+
res.swToken = encryptedToken;
|
|
414
|
+
|
|
376
415
|
return next();
|
|
377
416
|
}
|
|
378
417
|
|
|
379
|
-
//
|
|
418
|
+
// show login / password modal
|
|
380
419
|
res.writeHead(401, {
|
|
381
420
|
'WWW-Authenticate':'Basic',
|
|
382
421
|
'Content-Type':'text/plain',
|
|
383
422
|
});
|
|
423
|
+
|
|
384
424
|
res.end('Authentication required.');
|
|
385
425
|
} else {
|
|
386
|
-
// route not protected
|
|
426
|
+
// route is not protected
|
|
387
427
|
return next();
|
|
388
428
|
}
|
|
389
|
-
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
this.router.use(soundworksAuth);
|
|
390
432
|
}
|
|
391
433
|
|
|
392
434
|
// start http server
|
|
@@ -411,7 +453,7 @@ class Server {
|
|
|
411
453
|
// this fails with self-signed certificates for whatever reason...
|
|
412
454
|
try {
|
|
413
455
|
x509 = new X509Certificate(cert);
|
|
414
|
-
} catch(err) {
|
|
456
|
+
} catch (err) {
|
|
415
457
|
this._dispatchStatus('errored');
|
|
416
458
|
throw new Error(`[soundworks:Server] Invalid https cert file`);
|
|
417
459
|
}
|
|
@@ -423,7 +465,7 @@ class Server {
|
|
|
423
465
|
this._dispatchStatus('errored');
|
|
424
466
|
throw new Error(`[soundworks:Server] Invalid https key file`);
|
|
425
467
|
}
|
|
426
|
-
} catch(err) {
|
|
468
|
+
} catch (err) {
|
|
427
469
|
this._dispatchStatus('errored');
|
|
428
470
|
throw new Error(`[soundworks:Server] Invalid https key file`);
|
|
429
471
|
}
|
|
@@ -447,7 +489,7 @@ class Server {
|
|
|
447
489
|
};
|
|
448
490
|
|
|
449
491
|
this.httpServer = https.createServer({ key, cert }, this.router);
|
|
450
|
-
} catch(err) {
|
|
492
|
+
} catch (err) {
|
|
451
493
|
logger.error(`
|
|
452
494
|
Invalid certificate files, please check your:
|
|
453
495
|
- key file: ${httpsInfos.key}
|
|
@@ -591,7 +633,7 @@ Invalid certificate files, please check your:
|
|
|
591
633
|
await this.sockets.start(
|
|
592
634
|
this,
|
|
593
635
|
this.config.env.websockets,
|
|
594
|
-
(
|
|
636
|
+
(...args) => this._onSocketConnection(...args),
|
|
595
637
|
);
|
|
596
638
|
|
|
597
639
|
// ------------------------------------------------------------
|
|
@@ -710,6 +752,8 @@ Invalid certificate files, please check your:
|
|
|
710
752
|
route += `${role}`;
|
|
711
753
|
}
|
|
712
754
|
|
|
755
|
+
this.config.app.clients[role].route = route;
|
|
756
|
+
|
|
713
757
|
// define template filename: `${role}.html` or `default.html`
|
|
714
758
|
const {
|
|
715
759
|
templatePath,
|
|
@@ -726,7 +770,7 @@ Invalid certificate files, please check your:
|
|
|
726
770
|
try {
|
|
727
771
|
const stats = fs.statSync(clientTmpl);
|
|
728
772
|
template = stats.isFile() ? clientTmpl : defaultTmpl;
|
|
729
|
-
} catch(err) {
|
|
773
|
+
} catch (err) {
|
|
730
774
|
template = defaultTmpl;
|
|
731
775
|
}
|
|
732
776
|
|
|
@@ -734,15 +778,21 @@ Invalid certificate files, please check your:
|
|
|
734
778
|
|
|
735
779
|
try {
|
|
736
780
|
tmplString = fs.readFileSync(template, 'utf8');
|
|
737
|
-
} catch(err) {
|
|
781
|
+
} catch (err) {
|
|
738
782
|
throw new Error(`[soundworks:Server] html template file "${template}" not found`);
|
|
739
783
|
}
|
|
740
784
|
|
|
741
785
|
const tmpl = templateEngine.compile(tmplString);
|
|
742
|
-
|
|
743
|
-
|
|
786
|
+
|
|
787
|
+
const soundworksClientHandler = (req, res) => {
|
|
744
788
|
const data = clientConfigFunction(role, this.config, req);
|
|
745
789
|
|
|
790
|
+
// if the client has gone through the connection middleware (add succedeed),
|
|
791
|
+
// add the token to the data object
|
|
792
|
+
if (res.swToken) {
|
|
793
|
+
data.token = res.swToken;
|
|
794
|
+
}
|
|
795
|
+
|
|
746
796
|
// CORS / COOP / COEP headers for `crossOriginIsolated pages,
|
|
747
797
|
// enables `sharedArrayBuffers` and high precision timers
|
|
748
798
|
// cf. https://web.dev/why-coop-coep/
|
|
@@ -756,7 +806,10 @@ Invalid certificate files, please check your:
|
|
|
756
806
|
|
|
757
807
|
const appIndex = tmpl(data);
|
|
758
808
|
res.end(appIndex);
|
|
759
|
-
}
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
// http request
|
|
812
|
+
router.get(route, soundworksClientHandler);
|
|
760
813
|
|
|
761
814
|
// return route infos for logging on server start
|
|
762
815
|
return route;
|
|
@@ -766,18 +819,38 @@ Invalid certificate files, please check your:
|
|
|
766
819
|
* Socket connection callback.
|
|
767
820
|
* @private
|
|
768
821
|
*/
|
|
769
|
-
_onSocketConnection(role, socket) {
|
|
822
|
+
_onSocketConnection(role, socket, connectionToken) {
|
|
770
823
|
const client = new Client(role, socket);
|
|
771
824
|
const roles = Object.keys(this.config.app.clients);
|
|
772
825
|
|
|
826
|
+
// this has been validated
|
|
827
|
+
if (this.isProtected(role) && this.isValidConnectionToken(connectionToken)) {
|
|
828
|
+
const { ip } = decryptData(connectionToken);
|
|
829
|
+
const newData = {
|
|
830
|
+
ip: ip,
|
|
831
|
+
id: client.id,
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const newToken = encryptData(newData);
|
|
835
|
+
|
|
836
|
+
client.token = newToken;
|
|
837
|
+
|
|
838
|
+
this._pendingConnectionTokens.delete(connectionToken);
|
|
839
|
+
this._trustedClients.add(client);
|
|
840
|
+
}
|
|
841
|
+
|
|
773
842
|
socket.addListener('close', async () => {
|
|
774
|
-
// do
|
|
843
|
+
// do nothing if client role is invalid
|
|
775
844
|
if (roles.includes(role)) {
|
|
776
845
|
// decrement audit state counter
|
|
777
846
|
const numClients = this._auditState.get('numClients');
|
|
778
847
|
numClients[role] -= 1;
|
|
779
848
|
this._auditState.set({ numClients });
|
|
780
849
|
|
|
850
|
+
// delete token
|
|
851
|
+
if (this._trustedClients.has(client)) {
|
|
852
|
+
this._trustedClients.delete(client);
|
|
853
|
+
}
|
|
781
854
|
// clean context manager, await before cleaning state manager
|
|
782
855
|
await this.contextManager.removeClient(client);
|
|
783
856
|
// remove client from pluginManager
|
|
@@ -805,7 +878,7 @@ Invalid certificate files, please check your:
|
|
|
805
878
|
|
|
806
879
|
try {
|
|
807
880
|
this.pluginManager.checkRegisteredPlugins(registeredPlugins);
|
|
808
|
-
} catch(err) {
|
|
881
|
+
} catch (err) {
|
|
809
882
|
socket.send(CLIENT_HANDSHAKE_ERROR, {
|
|
810
883
|
type: 'invalid-plugin-list',
|
|
811
884
|
message: err.message,
|
|
@@ -830,8 +903,8 @@ Invalid certificate files, please check your:
|
|
|
830
903
|
// add client to context manager
|
|
831
904
|
await this.contextManager.addClient(client);
|
|
832
905
|
|
|
833
|
-
const { id, uuid } = client;
|
|
834
|
-
socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid });
|
|
906
|
+
const { id, uuid, token } = client;
|
|
907
|
+
socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token });
|
|
835
908
|
});
|
|
836
909
|
}
|
|
837
910
|
|
|
@@ -908,6 +981,27 @@ Invalid certificate files, please check your:
|
|
|
908
981
|
* you should rely on these defaults._
|
|
909
982
|
*/
|
|
910
983
|
useDefaultApplicationTemplate() {
|
|
984
|
+
const buildDirectory = path.join('.build', 'public');
|
|
985
|
+
|
|
986
|
+
const useMinifiedFile = {};
|
|
987
|
+
const roles = Object.keys(this.config.app.clients);
|
|
988
|
+
|
|
989
|
+
roles.forEach(role => {
|
|
990
|
+
if (this.config.env.type === 'production') {
|
|
991
|
+
// check if minified file exists
|
|
992
|
+
const minifiedFilePath = path.join(buildDirectory, `${role}.min.js`);
|
|
993
|
+
|
|
994
|
+
if (fs.existsSync(minifiedFilePath)) {
|
|
995
|
+
useMinifiedFile[role] = true;
|
|
996
|
+
} else {
|
|
997
|
+
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)`));
|
|
998
|
+
useMinifiedFile[role] = false;
|
|
999
|
+
}
|
|
1000
|
+
} else {
|
|
1001
|
+
useMinifiedFile[role] = false;
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
|
|
911
1005
|
this._applicationTemplateOptions = {
|
|
912
1006
|
templateEngine: { compile },
|
|
913
1007
|
templatePath: path.join('.build', 'server', 'tmpl'),
|
|
@@ -922,13 +1016,14 @@ Invalid certificate files, please check your:
|
|
|
922
1016
|
type: config.env.type,
|
|
923
1017
|
websockets: config.env.websockets,
|
|
924
1018
|
subpath: config.env.subpath,
|
|
1019
|
+
useMinifiedFile: useMinifiedFile[role],
|
|
925
1020
|
},
|
|
926
1021
|
};
|
|
927
1022
|
},
|
|
928
1023
|
};
|
|
929
1024
|
|
|
930
1025
|
this.router.use(express.static('public'));
|
|
931
|
-
this.router.use('/build', express.static(
|
|
1026
|
+
this.router.use('/build', express.static(buildDirectory));
|
|
932
1027
|
}
|
|
933
1028
|
|
|
934
1029
|
/**
|
|
@@ -964,6 +1059,81 @@ Invalid certificate files, please check your:
|
|
|
964
1059
|
|
|
965
1060
|
return this._auditState;
|
|
966
1061
|
}
|
|
1062
|
+
|
|
1063
|
+
/** @private */
|
|
1064
|
+
isProtected(role) {
|
|
1065
|
+
if (this.config.env.auth && Array.isArray(this.config.env.auth.clients)) {
|
|
1066
|
+
return this.config.env.auth.clients.includes(role);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return false;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** @private */
|
|
1073
|
+
isValidConnectionToken(token) {
|
|
1074
|
+
// token should be in pending token list
|
|
1075
|
+
if (!this._pendingConnectionTokens.has(token)) {
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// check the token is not too old
|
|
1080
|
+
const data = decryptData(token);
|
|
1081
|
+
const now = getTime();
|
|
1082
|
+
|
|
1083
|
+
// token is valid only for 30 seconds (this is arbitrary)
|
|
1084
|
+
if (now > data.time + TOKEN_VALID_DURATION) {
|
|
1085
|
+
// delete the token, is too old
|
|
1086
|
+
this._pendingConnectionTokens.delete(token);
|
|
1087
|
+
return false;
|
|
1088
|
+
} else {
|
|
1089
|
+
return true;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Check if the given client is trusted, i.e. config.env.type == 'production'
|
|
1095
|
+
* and the client is protected behind a password.
|
|
1096
|
+
*
|
|
1097
|
+
* @param {server.Client} client - Client to be tested
|
|
1098
|
+
* @returns {Boolean}
|
|
1099
|
+
*/
|
|
1100
|
+
isTrustedClient(client) {
|
|
1101
|
+
if (this.config.env.type !== 'production') {
|
|
1102
|
+
return true;
|
|
1103
|
+
} else {
|
|
1104
|
+
return this._trustedClients.has(client);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Check if the token from a client is trusted, i.e. config.env.type == 'production'
|
|
1110
|
+
* and the client is protected behind a password.
|
|
1111
|
+
*
|
|
1112
|
+
* @param {Number} clientId - Id of the client
|
|
1113
|
+
* @param {Number} clientIp - Ip of the client
|
|
1114
|
+
* @param {String} token - Token to be tested
|
|
1115
|
+
* @returns {Boolean}
|
|
1116
|
+
*/
|
|
1117
|
+
// for stateless interactions, e.g. POST files
|
|
1118
|
+
isTrustedToken(clientId, clientIp, token) {
|
|
1119
|
+
if (this.config.env.type !== 'production') {
|
|
1120
|
+
return true;
|
|
1121
|
+
} else {
|
|
1122
|
+
for (let client of this._trustedClients) {
|
|
1123
|
+
if (client.id === clientId && client.token === token) {
|
|
1124
|
+
// check that given token is consistent with client ip and id
|
|
1125
|
+
const { id, ip } = decryptData(client.token);
|
|
1126
|
+
|
|
1127
|
+
if (clientId === id && clientIp === ip) {
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
return false;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
967
1137
|
}
|
|
968
1138
|
|
|
969
1139
|
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,36 @@
|
|
|
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, at the execption of the ones
|
|
19
|
+
* created by the current node.
|
|
20
|
+
*
|
|
21
|
+
* ```
|
|
22
|
+
* const collection = await server.stateManager.getCollection('my-schema');
|
|
23
|
+
* const allValues = collection.getValues();
|
|
24
|
+
* collection.onUpdate((state, newValues, oldValues, context) => {
|
|
25
|
+
* // do something
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
* @memberof server
|
|
29
|
+
* @extends BaseSharedStateCollection
|
|
30
|
+
* @inheritdoc
|
|
31
|
+
* @hideconstructor
|
|
32
|
+
*/
|
|
33
|
+
class SharedStateCollection extends BaseSharedStateCollection {}
|
|
34
|
+
|
|
35
|
+
export default SharedStateCollection;
|
|
36
|
+
|
package/src/server/Sockets.js
CHANGED
|
@@ -2,9 +2,9 @@ import { Worker } from 'node:worker_threads';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
|
+
import querystring from 'querystring';
|
|
5
6
|
import { WebSocketServer } from 'ws';
|
|
6
7
|
import WebSocket from 'ws';
|
|
7
|
-
import querystring from 'querystring';
|
|
8
8
|
|
|
9
9
|
import Socket from './Socket.js';
|
|
10
10
|
|
|
@@ -69,13 +69,13 @@ class Sockets {
|
|
|
69
69
|
|
|
70
70
|
// init ws server
|
|
71
71
|
this._wss = new WebSocketServer({
|
|
72
|
-
|
|
72
|
+
noServer: true,
|
|
73
73
|
path: `/${config.path}`, // @note - update according to existing config files (aka cosima-apps)
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
this._wss.on('connection', (ws, req) => {
|
|
77
77
|
const queryString = querystring.decode(req.url.split('?')[1]);
|
|
78
|
-
const { role, key } = queryString;
|
|
78
|
+
const { role, key, token } = queryString;
|
|
79
79
|
const binary = !!(parseInt(queryString.binary));
|
|
80
80
|
|
|
81
81
|
if (binary) {
|
|
@@ -96,8 +96,29 @@ class Sockets {
|
|
|
96
96
|
socket.addToRoom('*');
|
|
97
97
|
socket.addToRoom(role);
|
|
98
98
|
|
|
99
|
-
onConnectionCallback(role, socket);
|
|
99
|
+
onConnectionCallback(role, socket, token);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// check if client can connect
|
|
104
|
+
server.httpServer.on('upgrade', async (req, socket, head) => {
|
|
105
|
+
const queryString = querystring.decode(req.url.split('?')[1]);
|
|
106
|
+
|
|
107
|
+
const { role, token } = queryString;
|
|
108
|
+
|
|
109
|
+
if (server.isProtected(role)) {
|
|
110
|
+
// we don't have any ip in the upgrade request, so we just check the
|
|
111
|
+
// connection token is pending
|
|
112
|
+
const allowed = server.isValidConnectionToken(token);
|
|
113
|
+
|
|
114
|
+
if (!allowed) {
|
|
115
|
+
socket.destroy('not allowed');
|
|
116
|
+
}
|
|
100
117
|
}
|
|
118
|
+
|
|
119
|
+
this._wss.handleUpgrade(req, socket, head, (ws) => {
|
|
120
|
+
this._wss.emit('connection', ws, req);
|
|
121
|
+
});
|
|
101
122
|
});
|
|
102
123
|
}
|
|
103
124
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import EventEmitter from 'node:events';
|
|
2
2
|
|
|
3
|
+
import { idGenerator, isString, isPlainObject } from '@ircam/sc-utils';
|
|
3
4
|
import clonedeep from 'lodash.clonedeep';
|
|
4
5
|
|
|
5
6
|
import BaseStateManager from '../common/BaseStateManager.js';
|
|
6
7
|
import ParameterBag from '../common/ParameterBag.js';
|
|
7
8
|
import SharedStatePrivate from '../common/SharedStatePrivate.js';
|
|
8
|
-
import { isString, isPlainObject } from '../common/utils.js';
|
|
9
9
|
import {
|
|
10
10
|
SERVER_ID,
|
|
11
11
|
CREATE_REQUEST,
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
DELETE_SCHEMA,
|
|
23
23
|
PRIVATE_STATES,
|
|
24
24
|
} from '../common/constants.js';
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
|
|
27
27
|
const generateStateId = idGenerator();
|
|
28
28
|
const generateRemoteId = idGenerator();
|
|
@@ -359,9 +359,11 @@ class StateManager extends BaseStateManager {
|
|
|
359
359
|
client.transport.emit(CREATE_RESPONSE, reqId, stateId, remoteId, schemaName, sendedSchema, currentValues);
|
|
360
360
|
|
|
361
361
|
this._observers.forEach(observer => {
|
|
362
|
-
observer.
|
|
362
|
+
if (observer.id !== nodeId) {
|
|
363
|
+
observer.transport.emit(OBSERVE_NOTIFICATION, schemaName, stateId, nodeId);
|
|
364
|
+
}
|
|
363
365
|
});
|
|
364
|
-
} catch(err) {
|
|
366
|
+
} catch (err) {
|
|
365
367
|
client.transport.emit(CREATE_ERROR, reqId, err.message);
|
|
366
368
|
console.error(err.message);
|
|
367
369
|
}
|
|
@@ -428,7 +430,7 @@ class StateManager extends BaseStateManager {
|
|
|
428
430
|
const { schemaName, id, _creatorId } = state;
|
|
429
431
|
// only track application states
|
|
430
432
|
// (e.g. do not propagate infos about audit state)
|
|
431
|
-
if (!PRIVATE_STATES.includes(schemaName)) {
|
|
433
|
+
if (!PRIVATE_STATES.includes(schemaName) && _creatorId !== client.id) {
|
|
432
434
|
statesInfos.push([schemaName, id, _creatorId]);
|
|
433
435
|
}
|
|
434
436
|
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
|
|
4
|
+
// adapted from https://dev.to/jobizil/encrypt-and-decrypt-data-in-nodejs-using-aes-256-cbc-2l6d
|
|
5
|
+
// generate a new key and init vector each time the server restart, we don't need
|
|
6
|
+
// any persistency here
|
|
7
|
+
const secretKey = uuidv4();
|
|
8
|
+
const secretIv = uuidv4();
|
|
9
|
+
const encryptionMethod = 'aes-256-cbc';
|
|
10
|
+
|
|
11
|
+
const key = crypto
|
|
12
|
+
.createHash('sha512')
|
|
13
|
+
.update(secretKey)
|
|
14
|
+
.digest('hex')
|
|
15
|
+
.substring(0, 32);
|
|
16
|
+
|
|
17
|
+
const encryptionIV = crypto
|
|
18
|
+
.createHash('sha512')
|
|
19
|
+
.update(secretIv)
|
|
20
|
+
.digest('hex')
|
|
21
|
+
.substring(0, 16);
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
export function encryptData(obj) {
|
|
25
|
+
const data = JSON.stringify(obj);
|
|
26
|
+
const cipher = crypto.createCipheriv(encryptionMethod, key, encryptionIV)
|
|
27
|
+
return Buffer.from(
|
|
28
|
+
cipher.update(data, 'utf8', 'hex') + cipher.final('hex')
|
|
29
|
+
).toString('base64')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function decryptData(encryptedData) {
|
|
33
|
+
const buff = Buffer.from(encryptedData, 'base64')
|
|
34
|
+
const decipher = crypto.createDecipheriv(encryptionMethod, key, encryptionIV)
|
|
35
|
+
|
|
36
|
+
const decrypted = `${decipher.update(buff.toString('utf8'), 'hex', 'utf8')}${decipher.final('utf8')}`
|
|
37
|
+
return JSON.parse(decrypted);
|
|
38
|
+
}
|
package/src/common/utils.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import isPlainObj from 'is-plain-obj';
|
|
2
|
-
|
|
3
|
-
// https://stackoverflow.com/questions/17575790/environment-detection-node-js-or-browser
|
|
4
|
-
export const isBrowser = new Function('try {return this===window;}catch(e){ return false;}');
|
|
5
|
-
|
|
6
|
-
export function* idGenerator() {
|
|
7
|
-
for (let i = 0; true; i++) {
|
|
8
|
-
if (i === Number.MAX_SAFE_INTEGER) {
|
|
9
|
-
i = 0;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
yield i;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function isString(val) {
|
|
17
|
-
return (typeof val === 'string' || val instanceof String);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function isFunction(func) {
|
|
21
|
-
return Object.prototype.toString.call(func) == '[object Function]' ||
|
|
22
|
-
Object.prototype.toString.call(func) == '[object AsyncFunction]';
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function isPlainObject(obj) {
|
|
26
|
-
return isPlainObj(obj);
|
|
27
|
-
}
|
|
28
|
-
|