@soundworks/core 4.0.0-alpha.5 → 4.0.0-alpha.7
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/package.json +4 -5
- package/src/client/Client.js +8 -1
- package/src/client/PluginManager.js +1 -1
- package/src/client/SharedStateCollection.js +35 -0
- package/src/client/Socket.js +16 -8
- package/src/common/BasePlugin.js +30 -20
- package/src/common/BasePluginManager.js +30 -14
- package/src/common/BaseSharedState.js +48 -13
- package/src/common/BaseSharedStateCollection.js +179 -0
- package/src/common/BaseStateManager.js +70 -16
- package/src/common/ParameterBag.js +63 -16
- package/src/server/Client.js +6 -0
- package/src/server/PluginManager.js +1 -1
- package/src/server/Server.js +183 -17
- package/src/server/SharedStateCollection.js +36 -0
- package/src/server/Sockets.js +25 -4
- package/src/server/StateManager.js +4 -2
- package/src/server/crypto.js +38 -0
- package/.github/workflows/main.yml +0 -15
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { isString, isFunction } from '@ircam/sc-utils';
|
|
1
2
|
import SharedState from './BaseSharedState.js';
|
|
3
|
+
import SharedStateCollection from './BaseSharedStateCollection.js';
|
|
2
4
|
import {
|
|
3
5
|
CREATE_REQUEST,
|
|
4
6
|
CREATE_RESPONSE,
|
|
@@ -31,7 +33,7 @@ class BaseStateManager {
|
|
|
31
33
|
this.client = { id, transport };
|
|
32
34
|
|
|
33
35
|
this._statesById = new Map();
|
|
34
|
-
this._observeListeners = new
|
|
36
|
+
this._observeListeners = new Map(); // Map <callback, filterSchemaName>
|
|
35
37
|
this._cachedSchemas = new Map();
|
|
36
38
|
this._observeRequestCallbacks = new Map();
|
|
37
39
|
|
|
@@ -88,28 +90,35 @@ class BaseStateManager {
|
|
|
88
90
|
this.client.transport.addListener(OBSERVE_RESPONSE, async (reqId, ...list) => {
|
|
89
91
|
// retrieve the callback that have been stored in observe to make sure
|
|
90
92
|
// we don't call another callback that may have been registered earlier.
|
|
91
|
-
const callback = this._observeRequestCallbacks.get(reqId);
|
|
93
|
+
const [callback, filterSchemaName] = this._observeRequestCallbacks.get(reqId);
|
|
92
94
|
this._observeRequestCallbacks.delete(reqId);
|
|
93
95
|
|
|
94
96
|
const promises = list.map(([schemaName, stateId, nodeId]) => {
|
|
95
|
-
|
|
97
|
+
if (filterSchemaName === '*' || filterSchemaName === schemaName) {
|
|
98
|
+
return callback(schemaName, stateId, nodeId);
|
|
99
|
+
} else {
|
|
100
|
+
return Promise.resolve();
|
|
101
|
+
}
|
|
96
102
|
});
|
|
97
103
|
|
|
98
104
|
await Promise.all(promises);
|
|
99
105
|
|
|
100
106
|
const unsubscribe = () => {
|
|
101
107
|
this._observeListeners.delete(callback);
|
|
102
|
-
|
|
108
|
+
// no more listeners, we can stop receiving notification from the server
|
|
103
109
|
if (this._observeListeners.size === 0) {
|
|
104
110
|
this.client.transport.emit(UNOBSERVE_NOTIFICATION);
|
|
105
111
|
}
|
|
106
112
|
};
|
|
113
|
+
|
|
107
114
|
resolveRequest(reqId, unsubscribe);
|
|
108
115
|
});
|
|
109
116
|
|
|
110
|
-
this.client.transport.addListener(OBSERVE_NOTIFICATION, (
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
this.client.transport.addListener(OBSERVE_NOTIFICATION, (schemaName, stateId, nodeId) => {
|
|
118
|
+
this._observeListeners.forEach((filterSchemaName, callback) => {
|
|
119
|
+
if (filterSchemaName === '*' || filterSchemaName === schemaName) {
|
|
120
|
+
callback(schemaName, stateId, nodeId);
|
|
121
|
+
}
|
|
113
122
|
});
|
|
114
123
|
});
|
|
115
124
|
|
|
@@ -170,12 +179,16 @@ class BaseStateManager {
|
|
|
170
179
|
* the state of all other clients of the application, to monitor them and/or take
|
|
171
180
|
* control over them from a single point.
|
|
172
181
|
*
|
|
173
|
-
*
|
|
182
|
+
* Note that the states that are created by the same node are not propagated through
|
|
183
|
+
* the observe callback.
|
|
184
|
+
*
|
|
185
|
+
* @param {String} [schemaName] - optionnal schema name to filter the observed
|
|
186
|
+
* states.
|
|
174
187
|
* @param {server.StateManager~ObserveCallback|client.StateManager~ObserveCallback}
|
|
175
188
|
* callback - Function to be called when a new state is created on the network.
|
|
176
|
-
* @returns {Promise<Function>} -
|
|
177
|
-
* as been executed
|
|
178
|
-
* allows to stop observing the network.
|
|
189
|
+
* @returns {Promise<Function>} - Returns a Promise that resolves when the given
|
|
190
|
+
* callback as been executed on each existing states. The promise value is a
|
|
191
|
+
* function which allows to stop observing the states on the network.
|
|
179
192
|
* @example
|
|
180
193
|
* client.stateManager.observe(async (schemaName, stateId, nodeId) => {
|
|
181
194
|
* if (schemaName === 'something') {
|
|
@@ -184,16 +197,57 @@ class BaseStateManager {
|
|
|
184
197
|
* }
|
|
185
198
|
* });
|
|
186
199
|
*/
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
200
|
+
// note: all filtering is done only on client-side as it is really more simple to
|
|
201
|
+
// handle this way and the network overhead is very low for observe notifications:
|
|
202
|
+
// i.e. schemaName, stateId, nodeId
|
|
203
|
+
async observe(...args) {
|
|
204
|
+
let filterSchemaName;
|
|
205
|
+
let callback;
|
|
206
|
+
|
|
207
|
+
if (args.length === 1) {
|
|
208
|
+
filterSchemaName = '*';
|
|
209
|
+
callback = args[0];
|
|
210
|
+
|
|
211
|
+
if (!isFunction(callback)) {
|
|
212
|
+
throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`);
|
|
213
|
+
}
|
|
214
|
+
} else if (args.length === 2) {
|
|
215
|
+
filterSchemaName = args[0];
|
|
216
|
+
callback = args[1];
|
|
217
|
+
|
|
218
|
+
if (!isString(filterSchemaName) || !isFunction(callback)) {
|
|
219
|
+
throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`);
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// resend request to get updated list of states
|
|
190
226
|
return new Promise((resolve, reject) => {
|
|
191
227
|
const reqId = storeRequestPromise(resolve, reject);
|
|
192
|
-
// store the callback
|
|
193
|
-
|
|
228
|
+
// store the callback for execution on the response. the returned Promise
|
|
229
|
+
// is fullfiled once callback has been executed with each existing states
|
|
230
|
+
this._observeRequestCallbacks.set(reqId, [callback, filterSchemaName]);
|
|
231
|
+
// store the callback for execution on subsequent notifications
|
|
232
|
+
this._observeListeners.set(callback, filterSchemaName);
|
|
233
|
+
|
|
194
234
|
this.client.transport.emit(OBSERVE_REQUEST, reqId);
|
|
195
235
|
});
|
|
196
236
|
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Returns a collection of all the states created from the schema name. Except
|
|
240
|
+
* the ones created by the current node.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} schemaName - Name of the schema.
|
|
243
|
+
* @returns {server.SharedStateCollection|client.SharedStateCollection}
|
|
244
|
+
*/
|
|
245
|
+
async getCollection(schemaName) {
|
|
246
|
+
const collection = new SharedStateCollection(this, schemaName);
|
|
247
|
+
await collection._init();
|
|
248
|
+
|
|
249
|
+
return collection;
|
|
250
|
+
}
|
|
197
251
|
}
|
|
198
252
|
|
|
199
253
|
export default BaseStateManager;
|
|
@@ -17,7 +17,7 @@ export const types = {
|
|
|
17
17
|
},
|
|
18
18
|
coerceFunction: (name, def, value) => {
|
|
19
19
|
if (typeof value !== 'boolean') {
|
|
20
|
-
throw new TypeError(`[SharedState] Invalid value for boolean
|
|
20
|
+
throw new TypeError(`[SharedState] Invalid value "${value}" for boolean parameter "${name}"`);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
return value;
|
|
@@ -30,7 +30,7 @@ export const types = {
|
|
|
30
30
|
},
|
|
31
31
|
coerceFunction: (name, def, value) => {
|
|
32
32
|
if (typeof value !== 'string') {
|
|
33
|
-
throw new TypeError(`[SharedState] Invalid value for string
|
|
33
|
+
throw new TypeError(`[SharedState] Invalid value "${value}" for string parameter "${name}"`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
return value;
|
|
@@ -63,7 +63,7 @@ export const types = {
|
|
|
63
63
|
},
|
|
64
64
|
coerceFunction: (name, def, value) => {
|
|
65
65
|
if (!(typeof value === 'number' && Math.floor(value) === value)) {
|
|
66
|
-
throw new TypeError(`[SharedState] Invalid value for integer
|
|
66
|
+
throw new TypeError(`[SharedState] Invalid value "${value}" for integer parameter "${name}"`);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
return Math.max(def.min, Math.min(def.max, value));
|
|
@@ -96,7 +96,7 @@ export const types = {
|
|
|
96
96
|
},
|
|
97
97
|
coerceFunction: (name, def, value) => {
|
|
98
98
|
if (typeof value !== 'number' || value !== value) { // reject NaN
|
|
99
|
-
throw new TypeError(`[SharedState] Invalid value for float
|
|
99
|
+
throw new TypeError(`[SharedState] Invalid value "${value}" for float parameter "${name}"`);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
return Math.max(def.min, Math.min(def.max, value));
|
|
@@ -109,7 +109,7 @@ export const types = {
|
|
|
109
109
|
},
|
|
110
110
|
coerceFunction: (name, def, value) => {
|
|
111
111
|
if (def.list.indexOf(value) === -1) {
|
|
112
|
-
throw new TypeError(`[SharedState] Invalid value for enum
|
|
112
|
+
throw new TypeError(`[SharedState] Invalid value "${value}" for enum parameter "${name}"`);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
|
|
@@ -241,7 +241,8 @@ class ParameterBag {
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
/**
|
|
244
|
-
* Return values of all parameters as a flat object.
|
|
244
|
+
* Return values of all parameters as a flat object. If a parameter is of `any`
|
|
245
|
+
* type, a deep copy is made.
|
|
245
246
|
*
|
|
246
247
|
* @return {object}
|
|
247
248
|
*/
|
|
@@ -255,6 +256,25 @@ class ParameterBag {
|
|
|
255
256
|
return values;
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Return values of all parameters as a flat object. Similar to `getValues` but
|
|
261
|
+
* returns a reference to the underlying value in case of `any` type. May be
|
|
262
|
+
* usefull if the underlying value is big (e.g. sensors recordings, etc.) and
|
|
263
|
+
* deep cloning expensive. Be aware that if changes are made on the returned
|
|
264
|
+
* object, the state of your application will become inconsistent.
|
|
265
|
+
*
|
|
266
|
+
* @return {object}
|
|
267
|
+
*/
|
|
268
|
+
getValuesUnsafe() {
|
|
269
|
+
let values = {};
|
|
270
|
+
|
|
271
|
+
for (let name in this._values) {
|
|
272
|
+
values[name] = this.getUnsafe(name);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return values;
|
|
276
|
+
}
|
|
277
|
+
|
|
258
278
|
/**
|
|
259
279
|
* Return the value of the given parameter. If the parameter is of `any` type,
|
|
260
280
|
* a deep copy is returned.
|
|
@@ -277,39 +297,66 @@ class ParameterBag {
|
|
|
277
297
|
}
|
|
278
298
|
|
|
279
299
|
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
300
|
+
* Similar to `get` but returns a reference to the underlying value in case of
|
|
301
|
+
* `any` type. May be usefull if the underlying value is big (e.g. sensors
|
|
302
|
+
* recordings, etc.) and deep cloning expensive. Be aware that if changes are
|
|
303
|
+
* made on the returned object, the state of your application will become
|
|
304
|
+
* inconsistent.
|
|
283
305
|
*
|
|
284
306
|
* @param {string} name - Name of the parameter.
|
|
307
|
+
* @return {Mixed} - Value of the parameter.
|
|
308
|
+
*/
|
|
309
|
+
getUnsafe(name) {
|
|
310
|
+
if (!this.has(name)) {
|
|
311
|
+
throw new ReferenceError(`[SharedState] Cannot get value of undefined parameter "${name}"`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return this._values[name];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Check that the value is valid according to the schema and return it coerced
|
|
319
|
+
* to the schema definition
|
|
320
|
+
*
|
|
321
|
+
* @param {String} name - Name of the parameter.
|
|
285
322
|
* @param {Mixed} value - Value of the parameter.
|
|
286
|
-
* @param {boolean} [forcePropagation=false] - if true, propagate value even
|
|
287
|
-
* if the value has not changed.
|
|
288
|
-
* @return {Array} - [new value, updated flag].
|
|
289
323
|
*/
|
|
290
|
-
|
|
324
|
+
coerceValue(name, value) {
|
|
291
325
|
if (!this.has(name)) {
|
|
292
326
|
throw new ReferenceError(`[SharedState] Cannot set value of undefined parameter "${name}"`);
|
|
293
327
|
}
|
|
294
328
|
|
|
295
329
|
const def = this._schema[name];
|
|
296
|
-
const { coerceFunction } = types[def.type];
|
|
297
330
|
|
|
298
331
|
if (value === null && def.nullable === false) {
|
|
299
332
|
throw new TypeError(`[SharedState] Invalid value for ${def.type} param "${name}": value is null and param is not nullable`);
|
|
300
333
|
} else if (value === null && def.nullable === true) {
|
|
301
334
|
value = null;
|
|
302
335
|
} else {
|
|
336
|
+
const { coerceFunction } = types[def.type];
|
|
303
337
|
value = coerceFunction(name, def, value);
|
|
304
338
|
}
|
|
305
339
|
|
|
340
|
+
return value;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Set the value of a parameter. If the value of the parameter is updated
|
|
345
|
+
* (aka if previous value is different from new value) all registered
|
|
346
|
+
* callbacks are registered.
|
|
347
|
+
*
|
|
348
|
+
* @param {string} name - Name of the parameter.
|
|
349
|
+
* @param {Mixed} value - Value of the parameter.
|
|
350
|
+
* @return {Array} - [new value, updated flag].
|
|
351
|
+
*/
|
|
352
|
+
set(name, value) {
|
|
353
|
+
value = this.coerceValue(name, value);
|
|
306
354
|
const currentValue = this._values[name];
|
|
307
355
|
const updated = !equal(currentValue, value);
|
|
308
356
|
|
|
309
357
|
// we store a deep copy of the object as we don't want the client to be able
|
|
310
|
-
// to modify our underlying data, which leads unexpected behavior where the
|
|
358
|
+
// to modify our underlying data, which leads to unexpected behavior where the
|
|
311
359
|
// deep equal check to returns true, and therefore the update is not triggered.
|
|
312
|
-
//
|
|
313
360
|
// @see tests/common.state-manager.spec.js
|
|
314
361
|
// 'should copy stored value for "any" type to have a predictable behavior'
|
|
315
362
|
if (this._schema[name].type === 'any') {
|
package/src/server/Client.js
CHANGED
|
@@ -78,7 +78,7 @@ import Server from './Server.js';
|
|
|
78
78
|
class PluginManager extends BasePluginManager {
|
|
79
79
|
constructor(server) {
|
|
80
80
|
if (!(server instanceof Server)) {
|
|
81
|
-
throw new Error(`[soundworks.PluginManager] Invalid argument, "new PluginManager(server)" should receive an instance of "soundworks.Server as argument
|
|
81
|
+
throw new Error(`[soundworks.PluginManager] Invalid argument, "new PluginManager(server)" should receive an instance of "soundworks.Server" as argument`);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
super(server);
|
package/src/server/Server.js
CHANGED
|
@@ -5,7 +5,8 @@ import path from 'node:path';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { X509Certificate, createPrivateKey } from 'node:crypto';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { getTime } from '@ircam/sc-gettime';
|
|
9
|
+
import { isPlainObject, idGenerator } from '@ircam/sc-utils';
|
|
9
10
|
import chalk from 'chalk';
|
|
10
11
|
import compression from 'compression';
|
|
11
12
|
import express from 'express';
|
|
@@ -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
|
*/
|
|
239
|
-
|
|
240
|
-
//
|
|
243
|
+
// @note: we use express() instead of express.Router() because all 404 and
|
|
244
|
+
// error stuff is handled by default
|
|
245
|
+
this.router = express();
|
|
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) {
|
|
370
|
+
const ids = idGenerator();
|
|
371
|
+
|
|
360
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
|
+
}
|
|
361
380
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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,17 +396,34 @@ 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
|
};
|
|
@@ -593,7 +633,7 @@ Invalid certificate files, please check your:
|
|
|
593
633
|
await this.sockets.start(
|
|
594
634
|
this,
|
|
595
635
|
this.config.env.websockets,
|
|
596
|
-
(
|
|
636
|
+
(...args) => this._onSocketConnection(...args),
|
|
597
637
|
);
|
|
598
638
|
|
|
599
639
|
// ------------------------------------------------------------
|
|
@@ -712,6 +752,8 @@ Invalid certificate files, please check your:
|
|
|
712
752
|
route += `${role}`;
|
|
713
753
|
}
|
|
714
754
|
|
|
755
|
+
this.config.app.clients[role].route = route;
|
|
756
|
+
|
|
715
757
|
// define template filename: `${role}.html` or `default.html`
|
|
716
758
|
const {
|
|
717
759
|
templatePath,
|
|
@@ -745,6 +787,12 @@ Invalid certificate files, please check your:
|
|
|
745
787
|
const soundworksClientHandler = (req, res) => {
|
|
746
788
|
const data = clientConfigFunction(role, this.config, req);
|
|
747
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
|
+
|
|
748
796
|
// CORS / COOP / COEP headers for `crossOriginIsolated pages,
|
|
749
797
|
// enables `sharedArrayBuffers` and high precision timers
|
|
750
798
|
// cf. https://web.dev/why-coop-coep/
|
|
@@ -759,6 +807,7 @@ Invalid certificate files, please check your:
|
|
|
759
807
|
const appIndex = tmpl(data);
|
|
760
808
|
res.end(appIndex);
|
|
761
809
|
};
|
|
810
|
+
|
|
762
811
|
// http request
|
|
763
812
|
router.get(route, soundworksClientHandler);
|
|
764
813
|
|
|
@@ -770,18 +819,38 @@ Invalid certificate files, please check your:
|
|
|
770
819
|
* Socket connection callback.
|
|
771
820
|
* @private
|
|
772
821
|
*/
|
|
773
|
-
_onSocketConnection(role, socket) {
|
|
822
|
+
_onSocketConnection(role, socket, connectionToken) {
|
|
774
823
|
const client = new Client(role, socket);
|
|
775
824
|
const roles = Object.keys(this.config.app.clients);
|
|
776
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
|
+
|
|
777
842
|
socket.addListener('close', async () => {
|
|
778
|
-
// do
|
|
843
|
+
// do nothing if client role is invalid
|
|
779
844
|
if (roles.includes(role)) {
|
|
780
845
|
// decrement audit state counter
|
|
781
846
|
const numClients = this._auditState.get('numClients');
|
|
782
847
|
numClients[role] -= 1;
|
|
783
848
|
this._auditState.set({ numClients });
|
|
784
849
|
|
|
850
|
+
// delete token
|
|
851
|
+
if (this._trustedClients.has(client)) {
|
|
852
|
+
this._trustedClients.delete(client);
|
|
853
|
+
}
|
|
785
854
|
// clean context manager, await before cleaning state manager
|
|
786
855
|
await this.contextManager.removeClient(client);
|
|
787
856
|
// remove client from pluginManager
|
|
@@ -834,8 +903,8 @@ Invalid certificate files, please check your:
|
|
|
834
903
|
// add client to context manager
|
|
835
904
|
await this.contextManager.addClient(client);
|
|
836
905
|
|
|
837
|
-
const { id, uuid } = client;
|
|
838
|
-
socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid });
|
|
906
|
+
const { id, uuid, token } = client;
|
|
907
|
+
socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token });
|
|
839
908
|
});
|
|
840
909
|
}
|
|
841
910
|
|
|
@@ -912,6 +981,27 @@ Invalid certificate files, please check your:
|
|
|
912
981
|
* you should rely on these defaults._
|
|
913
982
|
*/
|
|
914
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
|
+
|
|
915
1005
|
this._applicationTemplateOptions = {
|
|
916
1006
|
templateEngine: { compile },
|
|
917
1007
|
templatePath: path.join('.build', 'server', 'tmpl'),
|
|
@@ -926,13 +1016,14 @@ Invalid certificate files, please check your:
|
|
|
926
1016
|
type: config.env.type,
|
|
927
1017
|
websockets: config.env.websockets,
|
|
928
1018
|
subpath: config.env.subpath,
|
|
1019
|
+
useMinifiedFile: useMinifiedFile[role],
|
|
929
1020
|
},
|
|
930
1021
|
};
|
|
931
1022
|
},
|
|
932
1023
|
};
|
|
933
1024
|
|
|
934
1025
|
this.router.use(express.static('public'));
|
|
935
|
-
this.router.use('/build', express.static(
|
|
1026
|
+
this.router.use('/build', express.static(buildDirectory));
|
|
936
1027
|
}
|
|
937
1028
|
|
|
938
1029
|
/**
|
|
@@ -968,6 +1059,81 @@ Invalid certificate files, please check your:
|
|
|
968
1059
|
|
|
969
1060
|
return this._auditState;
|
|
970
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
|
+
}
|
|
971
1137
|
}
|
|
972
1138
|
|
|
973
1139
|
export default Server;
|