ep_webrtc 1.0.0 → 2.0.1
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 +76 -21
- package/ep.json +1 -0
- package/index.js +113 -32
- package/package.json +7 -3
- package/static/js/adapter.js +2 -2
- package/static/js/index.js +2 -2
package/README.md
CHANGED
|
@@ -74,20 +74,6 @@ Supported values for `"disabled"`:
|
|
|
74
74
|
* `"soft"`: Initially disabled by default.
|
|
75
75
|
* `"hard"`: Unavailable (it cannot be enabled).
|
|
76
76
|
|
|
77
|
-
The camera's record resolution can be configured by setting `videoConstraints`
|
|
78
|
-
to any [video
|
|
79
|
-
constraints](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#parameters)
|
|
80
|
-
value acceptable to client browsers. It has the following default value:
|
|
81
|
-
|
|
82
|
-
```json
|
|
83
|
-
"ep_webrtc": {
|
|
84
|
-
"videoConstraints": {
|
|
85
|
-
"width": {"ideal": 160},
|
|
86
|
-
"height": {"ideal": 120}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
```
|
|
90
|
-
|
|
91
77
|
### Custom Activate Button
|
|
92
78
|
|
|
93
79
|
The misnamed `listenClass` setting allows you to specify a CSS selector for an
|
|
@@ -134,6 +120,8 @@ example:
|
|
|
134
120
|
}
|
|
135
121
|
```
|
|
136
122
|
|
|
123
|
+
#### Ephemeral credentials
|
|
124
|
+
|
|
137
125
|
To limit abuse, the [coturn](https://github.com/coturn/coturn) TURN server
|
|
138
126
|
supports [ephemeral (temporary) usernames and
|
|
139
127
|
passwords](https://github.com/coturn/coturn/blob/60e7a199fe748cb7080594a458d22c2f7bb15a8c/README.turnserver#L664-L729).
|
|
@@ -165,13 +153,83 @@ Example:
|
|
|
165
153
|
"lifetime": 3600
|
|
166
154
|
}
|
|
167
155
|
]
|
|
168
|
-
}
|
|
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
|
+
},
|
|
169
186
|
```
|
|
170
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
|
+
|
|
171
209
|
### Video Sizes
|
|
172
210
|
|
|
173
|
-
|
|
174
|
-
|
|
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`:
|
|
175
233
|
|
|
176
234
|
```json
|
|
177
235
|
"ep_webrtc": {
|
|
@@ -181,12 +239,9 @@ one or both of the following in your `settings.json`:
|
|
|
181
239
|
"large": 400
|
|
182
240
|
}
|
|
183
241
|
}
|
|
184
|
-
}
|
|
242
|
+
},
|
|
185
243
|
```
|
|
186
244
|
|
|
187
|
-
This only controls the size of the video display widget. To set the camera's
|
|
188
|
-
record resolution, see the `videoConstraints` setting.
|
|
189
|
-
|
|
190
245
|
## Metrics
|
|
191
246
|
|
|
192
247
|
You can see metrics for various errors that users have when attempting to
|
package/ep.json
CHANGED
package/index.js
CHANGED
|
@@ -14,34 +14,54 @@
|
|
|
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
|
-
|
|
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
|
},
|
|
35
|
-
videoConstraints: {
|
|
36
|
-
width: {ideal: 160},
|
|
37
|
-
height: {ideal: 120},
|
|
38
|
-
},
|
|
39
49
|
iceServers: [{urls: ['stun:stun.l.google.com:19302']}],
|
|
40
50
|
listenClass: null,
|
|
41
51
|
moreInfoUrl: {},
|
|
42
52
|
};
|
|
53
|
+
let settings = null;
|
|
43
54
|
let socketio;
|
|
44
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
|
+
|
|
45
65
|
// Copied from:
|
|
46
66
|
// https://github.com/ether/etherpad-lite/blob/f95b09e0b6752a0d226d58d8b246831164dc9533/src/node/handler/PadMessageHandler.js#L1411-L1420
|
|
47
67
|
const _getRoomSockets = (padId) => {
|
|
@@ -102,21 +122,70 @@ const handleErrorStatMessage = (statName) => {
|
|
|
102
122
|
if (statErrorNames.includes(statName)) {
|
|
103
123
|
stats.meter(`ep_webrtc_err_${statName}`).mark();
|
|
104
124
|
} else {
|
|
105
|
-
|
|
125
|
+
logger.warn(`Invalid ep_webrtc error stat: ${statName}`);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
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);
|
|
106
137
|
}
|
|
138
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
139
|
+
return await res.json();
|
|
107
140
|
};
|
|
108
141
|
|
|
109
142
|
exports.clientVars = async (hookName, {clientVars: {userId: authorId}}) => ({ep_webrtc: {
|
|
110
143
|
...settings,
|
|
111
|
-
iceServers: settings.iceServers.map((server) => {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
})),
|
|
120
189
|
}});
|
|
121
190
|
|
|
122
191
|
exports.handleMessage = async (hookName, {message, socket}) => {
|
|
@@ -130,6 +199,22 @@ exports.handleMessage = async (hookName, {message, socket}) => {
|
|
|
130
199
|
}
|
|
131
200
|
};
|
|
132
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
|
+
|
|
133
218
|
exports.setSocketIO = (hookName, {io}) => { socketio = io; };
|
|
134
219
|
|
|
135
220
|
exports.eejsBlock_mySettings = (hookName, context) => {
|
|
@@ -143,27 +228,23 @@ exports.eejsBlock_styles = (hookName, context) => {
|
|
|
143
228
|
context.content += eejs.require('./templates/styles.html', {}, module);
|
|
144
229
|
};
|
|
145
230
|
|
|
146
|
-
exports.loadSettings = async (hookName, {settings: {ep_webrtc = {}}}) => {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// The own-property check on the target prevents prototype pollution. (Prototype pollution
|
|
152
|
-
// shouldn't be exploitable here because the source comes from admin-controlled settings.json,
|
|
153
|
-
// but the check is added anyway to hopefully pacify static analysis tools.)
|
|
154
|
-
if (Object.prototype.hasOwnProperty.call(target, k) && isObj(tv) && isObj(sv)) merge(tv, sv);
|
|
155
|
-
else target[k] = sv;
|
|
156
|
-
}
|
|
157
|
-
};
|
|
158
|
-
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
|
+
});
|
|
159
236
|
settings.configError = (() => {
|
|
160
237
|
for (const k of ['audio', 'video']) {
|
|
161
238
|
const {[k]: {disabled} = {}} = settings;
|
|
162
239
|
if (disabled != null && !['none', 'hard', 'soft'].includes(disabled)) {
|
|
163
|
-
|
|
240
|
+
logger.error(`Invalid value in settings.json for ep_webrtc.${k}.disabled`);
|
|
164
241
|
return true;
|
|
165
242
|
}
|
|
166
243
|
}
|
|
167
244
|
return false;
|
|
168
245
|
})();
|
|
246
|
+
logger.info('configured:', util.inspect({
|
|
247
|
+
...settings,
|
|
248
|
+
iceServers: settings.iceServers.map((s) => s.credential ? {...s, credential: '*****'} : s),
|
|
249
|
+
}, {depth: Infinity}));
|
|
169
250
|
};
|
package/package.json
CHANGED
|
@@ -5,21 +5,25 @@
|
|
|
5
5
|
"url": "git@github.com:ether/ep_webrtc.git",
|
|
6
6
|
"type": "git"
|
|
7
7
|
},
|
|
8
|
-
"version": "
|
|
8
|
+
"version": "2.0.1",
|
|
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.
|
|
26
|
+
"eslint-config-etherpad": "^3.0.5",
|
|
23
27
|
"typescript": "^4.6.2"
|
|
24
28
|
},
|
|
25
29
|
"scripts": {
|
package/static/js/adapter.js
CHANGED
|
@@ -2405,7 +2405,7 @@ function shimCreateOfferLegacy(window) {
|
|
|
2405
2405
|
}
|
|
2406
2406
|
}
|
|
2407
2407
|
} else if (offerOptions.offerToReceiveAudio === true && !audioTransceiver) {
|
|
2408
|
-
this.addTransceiver('audio');
|
|
2408
|
+
this.addTransceiver('audio', { direction: 'recvonly' });
|
|
2409
2409
|
}
|
|
2410
2410
|
|
|
2411
2411
|
if (typeof offerOptions.offerToReceiveVideo !== 'undefined') {
|
|
@@ -2430,7 +2430,7 @@ function shimCreateOfferLegacy(window) {
|
|
|
2430
2430
|
}
|
|
2431
2431
|
}
|
|
2432
2432
|
} else if (offerOptions.offerToReceiveVideo === true && !videoTransceiver) {
|
|
2433
|
-
this.addTransceiver('video');
|
|
2433
|
+
this.addTransceiver('video', { direction: 'recvonly' });
|
|
2434
2434
|
}
|
|
2435
2435
|
}
|
|
2436
2436
|
return origCreateOffer.apply(this, arguments);
|
package/static/js/index.js
CHANGED
|
@@ -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 && this._settings.
|
|
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;
|