ep_webrtc 0.1.99 → 1.1.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.
@@ -22,12 +22,12 @@ jobs:
22
22
  sudo apt install -y --no-install-recommends libreoffice libreoffice-pdfimport
23
23
  -
24
24
  name: Install etherpad core
25
- uses: actions/checkout@v2
25
+ uses: actions/checkout@v3
26
26
  with:
27
27
  repository: ether/etherpad-lite
28
28
  -
29
29
  name: Checkout plugin repository
30
- uses: actions/checkout@v2
30
+ uses: actions/checkout@v3
31
31
  with:
32
32
  path: ./node_modules/__tmp
33
33
  -
@@ -28,7 +28,7 @@ jobs:
28
28
  printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}'
29
29
  -
30
30
  name: Check out Etherpad core
31
- uses: actions/checkout@v2
31
+ uses: actions/checkout@v3
32
32
  with:
33
33
  repository: ether/etherpad-lite
34
34
  -
@@ -41,7 +41,7 @@ jobs:
41
41
  src/bin/doc/package-lock.json
42
42
  -
43
43
  name: Check out the plugin
44
- uses: actions/checkout@v2
44
+ uses: actions/checkout@v3
45
45
  with:
46
46
  path: ./node_modules/__tmp
47
47
  -
@@ -22,7 +22,7 @@ jobs:
22
22
  # conflicts with this plugin's clone, etherpad-lite must be cloned and
23
23
  # moved out before this plugin's repo is cloned to $GITHUB_WORKSPACE.
24
24
  -
25
- uses: actions/checkout@v2
25
+ uses: actions/checkout@v3
26
26
  with:
27
27
  repository: ether/etherpad-lite
28
28
  path: etherpad-lite
@@ -31,7 +31,7 @@ jobs:
31
31
  # etherpad-lite has been moved outside of $GITHUB_WORKSPACE, so it is now
32
32
  # safe to clone this plugin's repo to $GITHUB_WORKSPACE.
33
33
  -
34
- uses: actions/checkout@v2
34
+ uses: actions/checkout@v3
35
35
  # This is necessary for actions/setup-node because '..' can't be used in
36
36
  # cache-dependency-path.
37
37
  -
@@ -77,7 +77,7 @@ jobs:
77
77
  runs-on: ubuntu-latest
78
78
  steps:
79
79
  -
80
- uses: actions/checkout@v2
80
+ uses: actions/checkout@v3
81
81
  with:
82
82
  fetch-depth: 0
83
83
  -
package/README.md CHANGED
@@ -74,6 +74,20 @@ 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
+
77
91
  ### Custom Activate Button
78
92
 
79
93
  The misnamed `listenClass` setting allows you to specify a CSS selector for an
@@ -120,6 +134,8 @@ example:
120
134
  }
121
135
  ```
122
136
 
137
+ #### Ephemeral credentials
138
+
123
139
  To limit abuse, the [coturn](https://github.com/coturn/coturn) TURN server
124
140
  supports [ephemeral (temporary) usernames and
125
141
  passwords](https://github.com/coturn/coturn/blob/60e7a199fe748cb7080594a458d22c2f7bb15a8c/README.turnserver#L664-L729).
@@ -151,7 +167,36 @@ Example:
151
167
  "lifetime": 3600
152
168
  }
153
169
  ]
154
- }
170
+ },
171
+ ```
172
+
173
+ There is also support for ephemeral credentials from the
174
+ [Xirsys](https://xirsys.com/) [API](https://docs.xirsys.com/?pg=api-turn):
175
+
176
+ * `credentialType` (required): Must be set to the exact string `"xirsys
177
+ ephemeral credentials"`.
178
+ * `url` (required): The desired Xirsys TURN API endpoint.
179
+ * `username` (required): Your Xirsys username.
180
+ * `credential` (required): Your Xirsys API secret.
181
+ * `lifetime` (optional; defaults to 43200 = 12 hours): How long (in seconds)
182
+ the ephemeral credentials will remain valid after the user visits a pad.
183
+ After this amount of time, new TURN connections will fail until the user
184
+ reloads the page (which will generate a new password).
185
+
186
+ Example:
187
+
188
+ ```json
189
+ "ep_webrtc": {
190
+ "iceServers": [
191
+ {
192
+ "credentialType": "xirsys ephemeral credentials",
193
+ "url": "https://global.xirsys.net/_turn/myChannel",
194
+ "username": "myUsername",
195
+ "credential": "myPassword",
196
+ "lifetime": 3600
197
+ }
198
+ ]
199
+ },
155
200
  ```
156
201
 
157
202
  ### Video Sizes
@@ -170,6 +215,9 @@ one or both of the following in your `settings.json`:
170
215
  }
171
216
  ```
172
217
 
218
+ This only controls the size of the video display widget. To set the camera's
219
+ record resolution, see the `videoConstraints` setting.
220
+
173
221
  ## Metrics
174
222
 
175
223
  You can see metrics for various errors that users have when attempting to
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,15 +14,19 @@
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');
24
23
 
25
- const settings = {
24
+ let logger = {};
25
+ for (const level of ['debug', 'info', 'warn', 'error']) {
26
+ logger[level] = console[level].bind(console, 'ep_webrtc:');
27
+ }
28
+
29
+ const defaultSettings = {
26
30
  // The defaults here are overridden by the values in the `ep_webrtc` object from `settings.json`.
27
31
  enabled: true,
28
32
  audio: {
@@ -32,12 +36,26 @@ const settings = {
32
36
  disabled: 'none',
33
37
  sizes: {large: 260, small: 160},
34
38
  },
39
+ videoConstraints: {
40
+ width: {ideal: 160},
41
+ height: {ideal: 120},
42
+ },
35
43
  iceServers: [{urls: ['stun:stun.l.google.com:19302']}],
36
44
  listenClass: null,
37
45
  moreInfoUrl: {},
38
46
  };
47
+ let settings = null;
39
48
  let socketio;
40
49
 
50
+ const addContextToError = (err, pfx) => {
51
+ const newErr = new Error(`${pfx}${err.message}`, {cause: err});
52
+ if (Error.captureStackTrace) Error.captureStackTrace(newErr, addContextToError);
53
+ // Check for https://github.com/tc39/proposal-error-cause support, available in Node.js >= v16.10.
54
+ if (newErr.cause === err) return newErr;
55
+ err.message = `${pfx}${err.message}`;
56
+ return err;
57
+ };
58
+
41
59
  // Copied from:
42
60
  // https://github.com/ether/etherpad-lite/blob/f95b09e0b6752a0d226d58d8b246831164dc9533/src/node/handler/PadMessageHandler.js#L1411-L1420
43
61
  const _getRoomSockets = (padId) => {
@@ -98,21 +116,70 @@ const handleErrorStatMessage = (statName) => {
98
116
  if (statErrorNames.includes(statName)) {
99
117
  stats.meter(`ep_webrtc_err_${statName}`).mark();
100
118
  } else {
101
- statsLogger.warn(`Invalid ep_webrtc error stat: ${statName}`);
119
+ logger.warn(`Invalid ep_webrtc error stat: ${statName}`);
102
120
  }
103
121
  };
104
122
 
123
+ const fetchJson = async (url, opts = {}) => {
124
+ const c = new globalThis.AbortController();
125
+ const t = setTimeout(() => c.abort(), 5000);
126
+ let res;
127
+ try {
128
+ res = await globalThis.fetch(url, {signal: c.signal, ...opts});
129
+ } finally {
130
+ clearTimeout(t);
131
+ }
132
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
133
+ return await res.json();
134
+ };
135
+
105
136
  exports.clientVars = async (hookName, {clientVars: {userId: authorId}}) => ({ep_webrtc: {
106
137
  ...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
- }),
138
+ iceServers: await Promise.all(settings.iceServers.map(async (server) => {
139
+ switch (server.credentialType) {
140
+ case 'coturn ephemeral password': {
141
+ const {lifetime = 60 * 60 * 12 /* seconds */} = server;
142
+ const username = `${Math.floor(Date.now() / 1000) + lifetime}:${authorId}`;
143
+ const hmac = crypto.createHmac('sha1', server.credential);
144
+ hmac.update(username);
145
+ const credential = hmac.digest('base64');
146
+ return {urls: server.urls, username, credential};
147
+ }
148
+ case 'xirsys ephemeral credentials': {
149
+ const {
150
+ url,
151
+ username,
152
+ credential,
153
+ lifetime: expire = 12 * 60 * 60, // seconds
154
+ method = 'PUT',
155
+ headers: h = {},
156
+ jsonBody: b = {},
157
+ } = server;
158
+ // Can't set default values for the Content-Type and Authorization headers by using an
159
+ // object literal with spread (e.g., `{'content-type': 'foo', ...h}`) because the Headers
160
+ // constructor uses `.append()` internally instead of `.set()`. This matters if a header is
161
+ // repeated multiple times by using different mixes of upper- and lower-case letters.
162
+ const headers = new globalThis.Headers(h);
163
+ if (!headers.has('content-type')) headers.set('content-type', 'application/json');
164
+ if (username && !headers.has('authorization')) {
165
+ headers.set('authorization',
166
+ `Basic ${Buffer.from(`${username}:${credential}`).toString('base64')}`);
167
+ }
168
+ const body =
169
+ JSON.stringify(b && typeof b === 'object' ? {format: 'urls', expire, ...b} : b);
170
+ try {
171
+ const {v, s} = await fetchJson(url, {method, headers, body});
172
+ if (s !== 'ok') throw new Error(`API error: ${v}`);
173
+ return v.iceServers;
174
+ } catch (err) {
175
+ const newErr = addContextToError(err, 'failed to get TURN credentials: ');
176
+ logger.error(newErr.stack || newErr.toString());
177
+ throw newErr;
178
+ }
179
+ }
180
+ default: return server;
181
+ }
182
+ })),
116
183
  }});
117
184
 
118
185
  exports.handleMessage = async (hookName, {message, socket}) => {
@@ -126,6 +193,22 @@ exports.handleMessage = async (hookName, {message, socket}) => {
126
193
  }
127
194
  };
128
195
 
196
+ exports.init_ep_webrtc = async (hookName, {logger: l}) => {
197
+ if (l != null) logger = l;
198
+ // TODO: Remove this once all supported Node.js versions have the fetch API (added in Node.js
199
+ // v17.5.0 behind the --experimental-fetch flag).
200
+ if (!globalThis.fetch) {
201
+ // eslint-disable-next-line node/no-unsupported-features/es-syntax -- https://github.com/mysticatea/eslint-plugin-node/issues/250
202
+ const {default: fetch, Headers, Request, Response} = await import('node-fetch');
203
+ Object.assign(globalThis, {fetch, Headers, Request, Response});
204
+ }
205
+ // TODO: Remove this once all supported Node.js versions have AbortController (>= v15.4.0).
206
+ if (!globalThis.AbortController) {
207
+ // eslint-disable-next-line node/no-unsupported-features/es-syntax -- https://github.com/mysticatea/eslint-plugin-node/issues/250
208
+ globalThis.AbortController = (await import('abort-controller')).default;
209
+ }
210
+ };
211
+
129
212
  exports.setSocketIO = (hookName, {io}) => { socketio = io; };
130
213
 
131
214
  exports.eejsBlock_mySettings = (hookName, context) => {
@@ -139,27 +222,23 @@ exports.eejsBlock_styles = (hookName, context) => {
139
222
  context.content += eejs.require('./templates/styles.html', {}, module);
140
223
  };
141
224
 
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);
225
+ exports.loadSettings = async (hookName, {settings: {ep_webrtc: s = {}}}) => {
226
+ settings = _.mergeWith({}, defaultSettings, s, (objV, srcV, key, obj, src) => {
227
+ if (Array.isArray(srcV)) return _.cloneDeep(srcV); // Don't merge arrays, replace them.
228
+ if (src === s && key === 'videoConstraints') return _.cloneDeep(srcV);
229
+ });
155
230
  settings.configError = (() => {
156
231
  for (const k of ['audio', 'video']) {
157
232
  const {[k]: {disabled} = {}} = settings;
158
233
  if (disabled != null && !['none', 'hard', 'soft'].includes(disabled)) {
159
- configLogger.error(`Invalid value in settings.json for ep_webrtc.${k}.disabled`);
234
+ logger.error(`Invalid value in settings.json for ep_webrtc.${k}.disabled`);
160
235
  return true;
161
236
  }
162
237
  }
163
238
  return false;
164
239
  })();
240
+ logger.info('configured:', {
241
+ ...settings,
242
+ iceServers: settings.iceServers.map((s) => s.credential ? {...s, credential: '*****'} : s),
243
+ });
165
244
  };
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.99",
8
+ "version": "1.1.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
- "eslint": "^8.9.0",
22
- "eslint-config-etherpad": "^3.0.1",
23
- "typescript": "^4.5.5"
25
+ "eslint": "^8.10.0",
26
+ "eslint-config-etherpad": "^3.0.5",
27
+ "typescript": "^4.6.2"
24
28
  },
25
29
  "scripts": {
26
30
  "lint": "eslint .",
@@ -669,7 +669,7 @@ exports.rtc = new class {
669
669
  debug(`requesting permission to access ${devices.join(' and ')}`);
670
670
  const stream = await window.navigator.mediaDevices.getUserMedia({
671
671
  audio: addAudioTrack,
672
- video: addVideoTrack && {width: {ideal: 320}, height: {ideal: 240}},
672
+ video: addVideoTrack && this._settings.videoConstraints,
673
673
  });
674
674
  debug('successfully accessed device(s)');
675
675
  return stream;