ep_webrtc 0.1.100 → 2.0.0

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/README.md CHANGED
@@ -120,6 +120,8 @@ example:
120
120
  }
121
121
  ```
122
122
 
123
+ #### Ephemeral credentials
124
+
123
125
  To limit abuse, the [coturn](https://github.com/coturn/coturn) TURN server
124
126
  supports [ephemeral (temporary) usernames and
125
127
  passwords](https://github.com/coturn/coturn/blob/60e7a199fe748cb7080594a458d22c2f7bb15a8c/README.turnserver#L664-L729).
@@ -151,13 +153,83 @@ Example:
151
153
  "lifetime": 3600
152
154
  }
153
155
  ]
154
- }
156
+ },
157
+ ```
158
+
159
+ There is also support for ephemeral credentials from the
160
+ [Xirsys](https://xirsys.com/) [API](https://docs.xirsys.com/?pg=api-turn):
161
+
162
+ * `credentialType` (required): Must be set to the exact string `"xirsys
163
+ ephemeral credentials"`.
164
+ * `url` (required): The desired Xirsys TURN API endpoint.
165
+ * `username` (required): Your Xirsys username.
166
+ * `credential` (required): Your Xirsys API secret.
167
+ * `lifetime` (optional; defaults to 43200 = 12 hours): How long (in seconds)
168
+ the ephemeral credentials will remain valid after the user visits a pad.
169
+ After this amount of time, new TURN connections will fail until the user
170
+ reloads the page (which will generate a new password).
171
+
172
+ Example:
173
+
174
+ ```json
175
+ "ep_webrtc": {
176
+ "iceServers": [
177
+ {
178
+ "credentialType": "xirsys ephemeral credentials",
179
+ "url": "https://global.xirsys.net/_turn/myChannel",
180
+ "username": "myUsername",
181
+ "credential": "myPassword",
182
+ "lifetime": 3600
183
+ }
184
+ ]
185
+ },
155
186
  ```
156
187
 
188
+ ### Microphone Settings
189
+
190
+ The microphone can be configured by setting `audio.constraints` to any [audio
191
+ constraints](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#parameters)
192
+ value acceptable to client browsers. It has the following default value:
193
+
194
+ ```json
195
+ "ep_webrtc": {
196
+ "audio": {
197
+ "constraints": {
198
+ "autoGainControl": {"ideal": true},
199
+ "echoCancellation": {"ideal": true},
200
+ "noiseSuppression": {"ideal": true}
201
+ }
202
+ }
203
+ },
204
+ ```
205
+
206
+ For a full list of available constraints, see [the
207
+ standard](https://www.w3.org/TR/2022/CRD-mediacapture-streams-20220307/#constrainable-properties).
208
+
157
209
  ### Video Sizes
158
210
 
159
- To set a custom small and/or large size in pixels, for the video displays, set
160
- one or both of the following in your `settings.json`:
211
+ The camera's record resolution can be configured by setting `video.constraints`
212
+ to any [video
213
+ constraints](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#parameters)
214
+ value acceptable to client browsers. It has the following default value:
215
+
216
+ ```json
217
+ "ep_webrtc": {
218
+ "video": {
219
+ "constraints": {
220
+ "width": {"ideal": 160},
221
+ "height": {"ideal": 120}
222
+ }
223
+ }
224
+ },
225
+ ```
226
+
227
+ For a full list of available constraints, see [the
228
+ standard](https://www.w3.org/TR/2022/CRD-mediacapture-streams-20220307/#constrainable-properties).
229
+
230
+ Changing the record resolution does not change the size of the displayed video
231
+ widgets. To change the video widget size, set `video.sizes.small` and/or
232
+ `video.sizes.large`:
161
233
 
162
234
  ```json
163
235
  "ep_webrtc": {
@@ -167,7 +239,7 @@ one or both of the following in your `settings.json`:
167
239
  "large": 400
168
240
  }
169
241
  }
170
- }
242
+ },
171
243
  ```
172
244
 
173
245
  ## Metrics
package/ep.json CHANGED
@@ -5,6 +5,7 @@
5
5
  "hooks": {
6
6
  "clientVars": "",
7
7
  "handleMessage": "",
8
+ "init_ep_webrtc": "",
8
9
  "loadSettings": "",
9
10
  "socketio": ":setSocketIO",
10
11
  "eejsBlock_mySettings": "",
package/index.js CHANGED
@@ -14,21 +14,35 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ const _ = require('lodash');
18
+ const {Buffer} = require('buffer');
17
19
  const crypto = require('crypto');
18
- const log4js = require('ep_etherpad-lite/node_modules/log4js');
19
- const statsLogger = log4js.getLogger('stats');
20
- const configLogger = log4js.getLogger('configuration');
21
20
  const eejs = require('ep_etherpad-lite/node/eejs/');
22
21
  const sessioninfos = require('ep_etherpad-lite/node/handler/PadMessageHandler').sessioninfos;
23
22
  const stats = require('ep_etherpad-lite/node/stats');
23
+ const util = require('util');
24
24
 
25
- const settings = {
25
+ let logger = {};
26
+ for (const level of ['debug', 'info', 'warn', 'error']) {
27
+ logger[level] = console[level].bind(console, 'ep_webrtc:');
28
+ }
29
+
30
+ const defaultSettings = {
26
31
  // The defaults here are overridden by the values in the `ep_webrtc` object from `settings.json`.
27
32
  enabled: true,
28
33
  audio: {
34
+ constraints: {
35
+ autoGainControl: {ideal: true},
36
+ echoCancellation: {ideal: true},
37
+ noiseSuppression: {ideal: true},
38
+ },
29
39
  disabled: 'none',
30
40
  },
31
41
  video: {
42
+ constraints: {
43
+ width: {ideal: 160},
44
+ height: {ideal: 120},
45
+ },
32
46
  disabled: 'none',
33
47
  sizes: {large: 260, small: 160},
34
48
  },
@@ -36,8 +50,18 @@ const settings = {
36
50
  listenClass: null,
37
51
  moreInfoUrl: {},
38
52
  };
53
+ let settings = null;
39
54
  let socketio;
40
55
 
56
+ const addContextToError = (err, pfx) => {
57
+ const newErr = new Error(`${pfx}${err.message}`, {cause: err});
58
+ if (Error.captureStackTrace) Error.captureStackTrace(newErr, addContextToError);
59
+ // Check for https://github.com/tc39/proposal-error-cause support, available in Node.js >= v16.10.
60
+ if (newErr.cause === err) return newErr;
61
+ err.message = `${pfx}${err.message}`;
62
+ return err;
63
+ };
64
+
41
65
  // Copied from:
42
66
  // https://github.com/ether/etherpad-lite/blob/f95b09e0b6752a0d226d58d8b246831164dc9533/src/node/handler/PadMessageHandler.js#L1411-L1420
43
67
  const _getRoomSockets = (padId) => {
@@ -98,21 +122,70 @@ const handleErrorStatMessage = (statName) => {
98
122
  if (statErrorNames.includes(statName)) {
99
123
  stats.meter(`ep_webrtc_err_${statName}`).mark();
100
124
  } else {
101
- statsLogger.warn(`Invalid ep_webrtc error stat: ${statName}`);
125
+ logger.warn(`Invalid ep_webrtc error stat: ${statName}`);
102
126
  }
103
127
  };
104
128
 
129
+ const fetchJson = async (url, opts = {}) => {
130
+ const c = new globalThis.AbortController();
131
+ const t = setTimeout(() => c.abort(), 5000);
132
+ let res;
133
+ try {
134
+ res = await globalThis.fetch(url, {signal: c.signal, ...opts});
135
+ } finally {
136
+ clearTimeout(t);
137
+ }
138
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
139
+ return await res.json();
140
+ };
141
+
105
142
  exports.clientVars = async (hookName, {clientVars: {userId: authorId}}) => ({ep_webrtc: {
106
143
  ...settings,
107
- iceServers: settings.iceServers.map((server) => {
108
- if (server.credentialType !== 'coturn ephemeral password') return server;
109
- const {lifetime = 60 * 60 * 12 /* seconds */} = server;
110
- const username = `${Math.floor(Date.now() / 1000) + lifetime}:${authorId}`;
111
- const hmac = crypto.createHmac('sha1', server.credential);
112
- hmac.update(username);
113
- const credential = hmac.digest('base64');
114
- return {urls: server.urls, username, credential};
115
- }),
144
+ iceServers: await Promise.all(settings.iceServers.map(async (server) => {
145
+ switch (server.credentialType) {
146
+ case 'coturn ephemeral password': {
147
+ const {lifetime = 60 * 60 * 12 /* seconds */} = server;
148
+ const username = `${Math.floor(Date.now() / 1000) + lifetime}:${authorId}`;
149
+ const hmac = crypto.createHmac('sha1', server.credential);
150
+ hmac.update(username);
151
+ const credential = hmac.digest('base64');
152
+ return {urls: server.urls, username, credential};
153
+ }
154
+ case 'xirsys ephemeral credentials': {
155
+ const {
156
+ url,
157
+ username,
158
+ credential,
159
+ lifetime: expire = 12 * 60 * 60, // seconds
160
+ method = 'PUT',
161
+ headers: h = {},
162
+ jsonBody: b = {},
163
+ } = server;
164
+ // Can't set default values for the Content-Type and Authorization headers by using an
165
+ // object literal with spread (e.g., `{'content-type': 'foo', ...h}`) because the Headers
166
+ // constructor uses `.append()` internally instead of `.set()`. This matters if a header is
167
+ // repeated multiple times by using different mixes of upper- and lower-case letters.
168
+ const headers = new globalThis.Headers(h);
169
+ if (!headers.has('content-type')) headers.set('content-type', 'application/json');
170
+ if (username && !headers.has('authorization')) {
171
+ headers.set('authorization',
172
+ `Basic ${Buffer.from(`${username}:${credential}`).toString('base64')}`);
173
+ }
174
+ const body =
175
+ JSON.stringify(b && typeof b === 'object' ? {format: 'urls', expire, ...b} : b);
176
+ try {
177
+ const {v, s} = await fetchJson(url, {method, headers, body});
178
+ if (s !== 'ok') throw new Error(`API error: ${v}`);
179
+ return v.iceServers;
180
+ } catch (err) {
181
+ const newErr = addContextToError(err, 'failed to get TURN credentials: ');
182
+ logger.error(newErr.stack || newErr.toString());
183
+ throw newErr;
184
+ }
185
+ }
186
+ default: return server;
187
+ }
188
+ })),
116
189
  }});
117
190
 
118
191
  exports.handleMessage = async (hookName, {message, socket}) => {
@@ -126,6 +199,22 @@ exports.handleMessage = async (hookName, {message, socket}) => {
126
199
  }
127
200
  };
128
201
 
202
+ exports.init_ep_webrtc = async (hookName, {logger: l}) => {
203
+ if (l != null) logger = l;
204
+ // TODO: Remove this once all supported Node.js versions have the fetch API (added in Node.js
205
+ // v17.5.0 behind the --experimental-fetch flag).
206
+ if (!globalThis.fetch) {
207
+ // eslint-disable-next-line node/no-unsupported-features/es-syntax -- https://github.com/mysticatea/eslint-plugin-node/issues/250
208
+ const {default: fetch, Headers, Request, Response} = await import('node-fetch');
209
+ Object.assign(globalThis, {fetch, Headers, Request, Response});
210
+ }
211
+ // TODO: Remove this once all supported Node.js versions have AbortController (>= v15.4.0).
212
+ if (!globalThis.AbortController) {
213
+ // eslint-disable-next-line node/no-unsupported-features/es-syntax -- https://github.com/mysticatea/eslint-plugin-node/issues/250
214
+ globalThis.AbortController = (await import('abort-controller')).default;
215
+ }
216
+ };
217
+
129
218
  exports.setSocketIO = (hookName, {io}) => { socketio = io; };
130
219
 
131
220
  exports.eejsBlock_mySettings = (hookName, context) => {
@@ -139,27 +228,23 @@ exports.eejsBlock_styles = (hookName, context) => {
139
228
  context.content += eejs.require('./templates/styles.html', {}, module);
140
229
  };
141
230
 
142
- exports.loadSettings = async (hookName, {settings: {ep_webrtc = {}}}) => {
143
- const isObj = (o) => o != null && typeof o === 'object' && !Array.isArray(o);
144
- const merge = (target, source) => {
145
- for (const [k, sv] of Object.entries(source)) {
146
- const tv = target[k];
147
- // The own-property check on the target prevents prototype pollution. (Prototype pollution
148
- // shouldn't be exploitable here because the source comes from admin-controlled settings.json,
149
- // but the check is added anyway to hopefully pacify static analysis tools.)
150
- if (Object.prototype.hasOwnProperty.call(target, k) && isObj(tv) && isObj(sv)) merge(tv, sv);
151
- else target[k] = sv;
152
- }
153
- };
154
- merge(settings, ep_webrtc);
231
+ exports.loadSettings = async (hookName, {settings: {ep_webrtc: s = {}}}) => {
232
+ settings = _.mergeWith({}, defaultSettings, s, (objV, srcV, key, obj, src) => {
233
+ if (Array.isArray(srcV)) return _.cloneDeep(srcV); // Don't merge arrays, replace them.
234
+ if (src === s.video && key === 'constraints') return _.cloneDeep(srcV);
235
+ });
155
236
  settings.configError = (() => {
156
237
  for (const k of ['audio', 'video']) {
157
238
  const {[k]: {disabled} = {}} = settings;
158
239
  if (disabled != null && !['none', 'hard', 'soft'].includes(disabled)) {
159
- configLogger.error(`Invalid value in settings.json for ep_webrtc.${k}.disabled`);
240
+ logger.error(`Invalid value in settings.json for ep_webrtc.${k}.disabled`);
160
241
  return true;
161
242
  }
162
243
  }
163
244
  return false;
164
245
  })();
246
+ logger.info('configured:', util.inspect({
247
+ ...settings,
248
+ iceServers: settings.iceServers.map((s) => s.credential ? {...s, credential: '*****'} : s),
249
+ }, {depth: Infinity}));
165
250
  };
package/package.json CHANGED
@@ -5,22 +5,26 @@
5
5
  "url": "git@github.com:ether/ep_webrtc.git",
6
6
  "type": "git"
7
7
  },
8
- "version": "0.1.100",
8
+ "version": "2.0.0",
9
9
  "description": "WebRTC based audio/video chat to Etherpad",
10
10
  "author": "John McLear <john@mclear.co.uk>",
11
11
  "contributors": [],
12
12
  "engines": {
13
13
  "node": ">=12.17.0"
14
14
  },
15
- "dependencies": {},
15
+ "dependencies": {
16
+ "abort-controller": "^3.0.0",
17
+ "lodash": "^4.17.21",
18
+ "node-fetch": "^3.2.1"
19
+ },
16
20
  "funding": {
17
21
  "type": "individual",
18
22
  "url": "https://etherpad.org/"
19
23
  },
20
24
  "devDependencies": {
21
25
  "eslint": "^8.10.0",
22
- "eslint-config-etherpad": "^3.0.4",
23
- "typescript": "^4.5.5"
26
+ "eslint-config-etherpad": "^3.0.5",
27
+ "typescript": "^4.6.2"
24
28
  },
25
29
  "scripts": {
26
30
  "lint": "eslint .",
@@ -668,8 +668,8 @@ exports.rtc = new class {
668
668
  if (!addAudioTrack && !addVideoTrack) return new MediaStream();
669
669
  debug(`requesting permission to access ${devices.join(' and ')}`);
670
670
  const stream = await window.navigator.mediaDevices.getUserMedia({
671
- audio: addAudioTrack,
672
- video: addVideoTrack && {width: {ideal: 320}, height: {ideal: 240}},
671
+ audio: addAudioTrack && this._settings.audio.constraints,
672
+ video: addVideoTrack && this._settings.video.constraints,
673
673
  });
674
674
  debug('successfully accessed device(s)');
675
675
  return stream;