diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cd70e709..b88da3c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +Changes in [9.8.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.8.0) (2021-03-01) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.8.0-rc.1...v9.8.0) + + * No changes since rc.1 + +Changes in [9.8.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.8.0-rc.1) (2021-02-24) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.7.0...v9.8.0-rc.1) + + * Optimise prefixed logger + [\#1615](https://github.com/matrix-org/matrix-js-sdk/pull/1615) + * Add debug logs to encryption prep, take 3 + [\#1614](https://github.com/matrix-org/matrix-js-sdk/pull/1614) + * Add functions for upper & lowercase random strings + [\#1612](https://github.com/matrix-org/matrix-js-sdk/pull/1612) + * Room helpers for invite permissions and join rules + [\#1609](https://github.com/matrix-org/matrix-js-sdk/pull/1609) + * Fixed wording in "Adding video track with id" log + [\#1606](https://github.com/matrix-org/matrix-js-sdk/pull/1606) + * Add more debug logs to encryption prep + [\#1605](https://github.com/matrix-org/matrix-js-sdk/pull/1605) + * Add option to set ice candidate pool size + [\#1604](https://github.com/matrix-org/matrix-js-sdk/pull/1604) + * Cancel call if no source was selected + [\#1601](https://github.com/matrix-org/matrix-js-sdk/pull/1601) + Changes in [9.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.7.0) (2021-02-16) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.7.0-rc.1...v9.7.0) diff --git a/package.json b/package.json index a963428b7..f9fafb132 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "9.7.0", + "version": "9.8.0", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", @@ -15,7 +15,7 @@ "build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js", "gendoc": "jsdoc -c jsdoc.json -P package.json", "lint": "yarn lint:types && yarn lint:js", - "lint:js": "eslint --max-warnings 73 src spec", + "lint:js": "eslint --max-warnings 72 src spec", "lint:types": "tsc --noEmit", "test": "jest spec/ --coverage --testEnvironment node", "test:watch": "jest spec/ --coverage --testEnvironment node --watch" diff --git a/spec/unit/crypto/algorithms/olm.spec.js b/spec/unit/crypto/algorithms/olm.spec.js index 0efe74e77..c548355b5 100644 --- a/spec/unit/crypto/algorithms/olm.spec.js +++ b/spec/unit/crypto/algorithms/olm.spec.js @@ -190,5 +190,91 @@ describe("OlmDevice", function() { // new session and will have called claimOneTimeKeys expect(count).toBe(2); }); + + it("avoids deadlocks when two tasks are ensuring the same devices", async function() { + // This test checks whether `ensureOlmSessionsForDevices` properly + // handles multiple tasks in flight ensuring some set of devices in + // common without deadlocks. + + let claimRequestCount = 0; + const baseApis = { + claimOneTimeKeys: () => { + // simulate a very slow server (.5 seconds to respond) + claimRequestCount++; + return new Promise((resolve, reject) => { + setTimeout(reject, 500); + }); + }, + }; + + const deviceBobA = DeviceInfo.fromStorage({ + keys: { + "curve25519:BOB-A": "akey", + }, + }, "BOB-A"); + const deviceBobB = DeviceInfo.fromStorage({ + keys: { + "curve25519:BOB-B": "bkey", + }, + }, "BOB-B"); + + // There's no required ordering of devices per user, so here we + // create two different orderings so that each task reserves a + // device the other task needs before continuing. + const devicesByUserAB = { + "@bob:example.com": [ + deviceBobA, + deviceBobB, + ], + }; + const devicesByUserBA = { + "@bob:example.com": [ + deviceBobB, + deviceBobA, + ], + }; + + function alwaysSucceed(promise) { + // swallow any exception thrown by a promise, so that + // Promise.all doesn't abort + return promise.catch(() => {}); + } + + const task1 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices( + aliceOlmDevice, baseApis, devicesByUserAB, + )); + + // After a single tick through the first task, it should have + // claimed ownership of all devices to avoid deadlocking others. + expect(Object.keys(aliceOlmDevice._sessionsInProgress).length).toBe(2); + + const task2 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices( + aliceOlmDevice, baseApis, devicesByUserBA, + )); + + // The second task should not have changed the ownership count, as + // it's waiting on the first task. + expect(Object.keys(aliceOlmDevice._sessionsInProgress).length).toBe(2); + + // Track the tasks, but don't await them yet. + const promises = Promise.all([ + task1, + task2, + ]); + + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + // After .2s, the first task should have made an initial claim request. + expect(claimRequestCount).toBe(1); + + await promises; + + // After waiting for both tasks to complete, the first task should + // have failed, so the second task should have tried to create a + // new session and will have called claimOneTimeKeys + expect(claimRequestCount).toBe(2); + }); }); }); diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index 06da02502..7336a7371 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -190,4 +190,62 @@ describe('Call', function() { // Hangup to stop timers call.hangup(CallErrorCode.UserHangup, true); }); + + it('should add candidates received before answer if party ID is correct', async function() { + await call.placeVoiceCall(); + call.peerConn.addIceCandidate = jest.fn(); + + call.onRemoteIceCandidatesReceived({ + getContent: () => { + return { + version: 1, + call_id: call.callId, + party_id: 'the_correct_party_id', + candidates: [ + { + candidate: 'the_correct_candidate', + sdpMid: '', + }, + ], + }; + }, + }); + + call.onRemoteIceCandidatesReceived({ + getContent: () => { + return { + version: 1, + call_id: call.callId, + party_id: 'some_other_party_id', + candidates: [ + { + candidate: 'the_wrong_candidate', + sdpMid: '', + }, + ], + }; + }, + }); + + expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(0); + + await call.onAnswerReceived({ + getContent: () => { + return { + version: 1, + call_id: call.callId, + party_id: 'the_correct_party_id', + answer: { + sdp: DUMMY_SDP, + }, + }; + }, + }); + + expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(1); + expect(call.peerConn.addIceCandidate).toHaveBeenCalledWith({ + candidate: 'the_correct_candidate', + sdpMid: '', + }); + }); }); diff --git a/src/@types/event.ts b/src/@types/event.ts index d54920a62..adffcd560 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -36,6 +36,10 @@ export enum EventType { */ RoomAliases = "m.room.aliases", // deprecated https://matrix.org/docs/spec/client_server/r0.6.1#historical-events + // Spaces MSC1772 + SpaceChild = "org.matrix.msc1772.space.child", + SpaceParent = "org.matrix.msc1772.space.parent", + // Room timeline events RoomRedaction = "m.room.redaction", RoomMessage = "m.room.message", @@ -87,3 +91,9 @@ export enum MsgType { Location = "m.location", Video = "m.video", } + +export const RoomCreateTypeField = "org.matrix.msc1772.type"; // Spaces MSC1772 + +export enum RoomType { + Space = "org.matrix.msc1772.space", // Spaces MSC1772 +} diff --git a/src/base-apis.js b/src/base-apis.js index 07694596b..37c3d76d6 100644 --- a/src/base-apis.js +++ b/src/base-apis.js @@ -2374,3 +2374,26 @@ MatrixBaseApis.prototype.reportEvent = function(roomId, eventId, score, reason) return this._http.authedRequest(undefined, "POST", path, null, {score, reason}); }; +/** + * Fetches or paginates a summary of a space as defined by MSC2946 + * @param {string} roomId The ID of the space-room to use as the root of the summary. + * @param {number?} maxRoomsPerSpace The maximum number of rooms to return per subspace. + * @param {boolean?} autoJoinOnly Whether to only return rooms with auto_join=true. + * @param {number?} limit The maximum number of rooms to return in total. + * @param {string?} batch The opaque token to paginate a previous summary request. + * @returns {Promise} the response, with next_batch, rooms, events fields. + */ +MatrixBaseApis.prototype.getSpaceSummary = function(roomId, maxRoomsPerSpace, autoJoinOnly, limit, batch) { + const path = utils.encodeUri("/rooms/$roomId/spaces", { + $roomId: roomId, + }); + + return this._http.authedRequest(undefined, "POST", path, null, { + max_rooms_per_space: maxRoomsPerSpace, + auto_join_only: autoJoinOnly, + limit, + batch, + }, { + prefix: "/_matrix/client/unstable/org.matrix.msc2946", + }); +}; diff --git a/src/client.js b/src/client.js index 5e224a5bf..4be20f8c0 100644 --- a/src/client.js +++ b/src/client.js @@ -393,6 +393,9 @@ export function MatrixClient(opts) { this._clientWellKnown = undefined; this._clientWellKnownPromise = undefined; + this._turnServers = []; + this._turnServersExpiry = null; + // The SDK doesn't really provide a clean way for events to recalculate the push // actions for themselves, so we have to kinda help them out when they are encrypted. // We do this so that push rules are correctly executed on events in their decrypted @@ -4942,6 +4945,15 @@ MatrixClient.prototype.getTurnServers = function() { return this._turnServers || []; }; +/** + * Get the unix timestamp (in seconds) at which the current + * TURN credentials (from getTurnServers) expire + * @return {number} The expiry timestamp, in seconds, or null if no credentials + */ +MatrixClient.prototype.getTurnServersExpiry = function() { + return this._turnServersExpiry; +}; + /** * Set whether to allow a fallback ICE server should be used for negotiating a * WebRTC connection if the homeserver doesn't provide any servers. Defaults to @@ -5413,6 +5425,9 @@ async function(roomId, eventId, relationType, eventType, opts = {}) { })); events = events.filter(e => e.getType() === eventType); } + if (originalEvent && relationType === "m.replace") { + events = events.filter(e => e.getSender() === originalEvent.getSender()); + } return { originalEvent, events, @@ -5437,6 +5452,7 @@ function checkTurnServers(client) { credential: res.password, }; client._turnServers = [servers]; + client._turnServersExpiry = Date.now() + res.ttl; // re-fetch when we're about to reach the TTL client._checkTurnServersTimeoutID = setTimeout(() => { checkTurnServers(client); diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index 24565507d..0989d19f3 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -545,6 +545,7 @@ OlmDevice.prototype.createOutboundSession = async function( } }); }, + logger.withPrefix("[createOutboundSession]"), ); return newSessionId; }; @@ -605,6 +606,7 @@ OlmDevice.prototype.createInboundSession = async function( } }); }, + logger.withPrefix("[createInboundSession]"), ); return result; @@ -619,8 +621,10 @@ OlmDevice.prototype.createInboundSession = async function( * @return {Promise} a list of known session ids for the device */ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityKey) { + const log = logger.withPrefix("[getSessionIdsForDevice]"); + if (this._sessionsInProgress[theirDeviceIdentityKey]) { - logger.log("waiting for olm session to be created"); + log.debug(`Waiting for Olm session for ${theirDeviceIdentityKey} to be created`); try { await this._sessionsInProgress[theirDeviceIdentityKey]; } catch (e) { @@ -638,6 +642,7 @@ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityK }, ); }, + log, ); return sessionIds; @@ -651,13 +656,14 @@ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityK * @param {boolean} nowait Don't wait for an in-progress session to complete. * This should only be set to true of the calling function is the function * that marked the session as being in-progress. + * @param {Logger} [log] A possibly customised log * @return {Promise} session id, or null if no established session */ OlmDevice.prototype.getSessionIdForDevice = async function( - theirDeviceIdentityKey, nowait, + theirDeviceIdentityKey, nowait, log, ) { const sessionInfos = await this.getSessionInfoForDevice( - theirDeviceIdentityKey, nowait, + theirDeviceIdentityKey, nowait, log, ); if (sessionInfos.length === 0) { @@ -697,11 +703,16 @@ OlmDevice.prototype.getSessionIdForDevice = async function( * @param {boolean} nowait Don't wait for an in-progress session to complete. * This should only be set to true of the calling function is the function * that marked the session as being in-progress. + * @param {Logger} [log] A possibly customised log * @return {Array.<{sessionId: string, hasReceivedMessage: Boolean}>} */ -OlmDevice.prototype.getSessionInfoForDevice = async function(deviceIdentityKey, nowait) { +OlmDevice.prototype.getSessionInfoForDevice = async function( + deviceIdentityKey, nowait, log = logger, +) { + log = log.withPrefix("[getSessionInfoForDevice]"); + if (this._sessionsInProgress[deviceIdentityKey] && !nowait) { - logger.log("waiting for olm session to be created"); + log.debug(`Waiting for Olm session for ${deviceIdentityKey} to be created`); try { await this._sessionsInProgress[deviceIdentityKey]; } catch (e) { @@ -727,6 +738,7 @@ OlmDevice.prototype.getSessionInfoForDevice = async function(deviceIdentityKey, } }); }, + log, ); return info; @@ -761,6 +773,7 @@ OlmDevice.prototype.encryptMessage = async function( this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); }); }, + logger.withPrefix("[encryptMessage]"), ); return res; }; @@ -794,6 +807,7 @@ OlmDevice.prototype.decryptMessage = async function( this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); }); }, + logger.withPrefix("[decryptMessage]"), ); return payloadString; }; @@ -825,6 +839,7 @@ OlmDevice.prototype.matchesSession = async function( matches = sessionInfo.session.matches_inbound(ciphertext); }); }, + logger.withPrefix("[matchesSession]"), ); return matches; }; @@ -1095,6 +1110,7 @@ OlmDevice.prototype.addInboundGroupSession = async function( }, ); }, + logger.withPrefix("[addInboundGroupSession]"), ); }; @@ -1265,6 +1281,7 @@ OlmDevice.prototype.decryptGroupMessage = async function( }, ); }, + logger.withPrefix("[decryptGroupMessage]"), ); if (error) { @@ -1310,6 +1327,7 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se }, ); }, + logger.withPrefix("[hasInboundSessionKeys]"), ); return result; @@ -1369,6 +1387,7 @@ OlmDevice.prototype.getInboundGroupSessionKey = async function( }, ); }, + logger.withPrefix("[getInboundGroupSessionKey]"), ); return result; diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 0fa3d046d..7ea048d2b 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -271,7 +271,7 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( logger.debug(`Shared keys with existing Olm sessions in ${this._roomId}`); })(), (async () => { - logger.debug(`Sharing keys with new Olm sessions in ${this._roomId}`); + logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this._roomId}`); const errorDevices = []; // meanwhile, establish olm sessions for devices that we don't @@ -285,6 +285,7 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( session, key, payload, devicesWithoutSession, errorDevices, singleOlmCreationPhase ? 10000 : 2000, failedServers, ); + logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this._roomId}`); if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { // perform the second phase of olm session creation if requested, @@ -313,21 +314,24 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( } } + logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this._roomId}`); await this._shareKeyWithDevices( session, key, payload, retryDevices, failedDevices, 30000, ); + logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this._roomId}`); await this._notifyFailedOlmDevices(session, key, failedDevices); })(); } else { await this._notifyFailedOlmDevices(session, key, errorDevices); } - logger.debug(`Shared keys with new Olm sessions in ${this._roomId}`); + logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this._roomId}`); })(), (async () => { logger.debug(`Notifying blocked devices in ${this._roomId}`); // also, notify blocked devices that they're blocked const blockedMap = {}; + let blockedCount = 0; for (const [userId, userBlockedDevices] of Object.entries(blocked)) { for (const [deviceId, device] of Object.entries(userBlockedDevices)) { if ( @@ -335,13 +339,14 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( session.blockedDevicesNotified[userId][deviceId] === undefined ) { blockedMap[userId] = blockedMap[userId] || {}; - blockedMap[userId][deviceId] = {device}; + blockedMap[userId][deviceId] = { device }; + blockedCount++; } } } await this._notifyBlockedDevices(session, blockedMap); - logger.debug(`Notified blocked devices in ${this._roomId}`); + logger.debug(`Notified ${blockedCount} blocked devices in ${this._roomId}`); })(), ]); }; @@ -728,13 +733,18 @@ MegolmEncryption.prototype.reshareKeyWithDevice = async function( MegolmEncryption.prototype._shareKeyWithDevices = async function( session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers, ) { + logger.debug(`Ensuring Olm sessions for devices in ${this._roomId}`); const devicemap = await olmlib.ensureOlmSessionsForDevices( this._olmDevice, this._baseApis, devicesByUser, otkTimeout, failedServers, + logger.withPrefix(`[${this._roomId}]`), ); + logger.debug(`Ensured Olm sessions for devices in ${this._roomId}`); this._getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); + logger.debug(`Sharing keys with Olm sessions in ${this._roomId}`); await this._shareKeyWithOlmSessions(session, key, payload, devicemap); + logger.debug(`Shared keys with Olm sessions in ${this._roomId}`); }; MegolmEncryption.prototype._shareKeyWithOlmSessions = async function( @@ -772,6 +782,11 @@ MegolmEncryption.prototype._shareKeyWithOlmSessions = async function( MegolmEncryption.prototype._notifyFailedOlmDevices = async function( session, key, failedDevices, ) { + logger.debug( + `Notifying ${failedDevices.length} devices we failed to ` + + `create Olm sessions in ${this._roomId}`, + ); + // mark the devices that failed as "handled" because we don't want to try // to claim a one-time-key for dead devices on every message. for (const {userId, deviceInfo} of failedDevices) { @@ -786,6 +801,10 @@ MegolmEncryption.prototype._notifyFailedOlmDevices = async function( await this._olmDevice.filterOutNotifiedErrorDevices( failedDevices, ); + logger.debug( + `Filtered down to ${filteredFailedDevices.length} error devices ` + + `in ${this._roomId}`, + ); const blockedMap = {}; for (const {userId, deviceInfo} of filteredFailedDevices) { blockedMap[userId] = blockedMap[userId] || {}; @@ -803,6 +822,10 @@ MegolmEncryption.prototype._notifyFailedOlmDevices = async function( // send the notifications await this._notifyBlockedDevices(session, blockedMap); + logger.debug( + `Notified ${filteredFailedDevices.length} devices we failed to ` + + `create Olm sessions in ${this._roomId}`, + ); }; /** diff --git a/src/crypto/olmlib.js b/src/crypto/olmlib.js index f55a9122e..eb0d8a4a9 100644 --- a/src/crypto/olmlib.js +++ b/src/crypto/olmlib.js @@ -183,18 +183,24 @@ export async function getExistingOlmSessions( * @param {Array} [failedServers] An array to fill with remote servers that * failed to respond to one-time-key requests. * + * @param {Logger} [log] A possibly customised log + * * @return {Promise} resolves once the sessions are complete, to * an Object mapping from userId to deviceId to * {@link module:crypto~OlmSessionResult} */ export async function ensureOlmSessionsForDevices( - olmDevice, baseApis, devicesByUser, force, otkTimeout, failedServers, + olmDevice, baseApis, devicesByUser, force, otkTimeout, failedServers, log, ) { if (typeof force === "number") { + log = failedServers; failedServers = otkTimeout; otkTimeout = force; force = false; } + if (!log) { + log = logger; + } const devicesWithoutSession = [ // [userId, deviceId], ... @@ -202,6 +208,39 @@ export async function ensureOlmSessionsForDevices( const result = {}; const resolveSession = {}; + // Mark all sessions this task intends to update as in progress. It is + // important to do this for all devices this task cares about in a single + // synchronous operation, as otherwise it is possible to have deadlocks + // where multiple tasks wait indefinitely on another task to update some set + // of common devices. + for (const [userId, devices] of Object.entries(devicesByUser)) { + for (const deviceInfo of devices) { + const deviceId = deviceInfo.deviceId; + const key = deviceInfo.getIdentityKey(); + + if (key === olmDevice.deviceCurve25519Key) { + // We don't start sessions with ourself, so there's no need to + // mark it in progress. + continue; + } + + const forWhom = `for ${key} (${userId}:${deviceId})`; + if (!olmDevice._sessionsInProgress[key]) { + // pre-emptively mark the session as in-progress to avoid race + // conditions. If we find that we already have a session, then + // we'll resolve + log.debug(`Marking Olm session in progress ${forWhom}`); + olmDevice._sessionsInProgress[key] = new Promise(resolve => { + resolveSession[key] = (...args) => { + log.debug(`Resolved Olm session in progress ${forWhom}`); + delete olmDevice._sessionsInProgress[key]; + resolve(...args); + }; + }); + } + } + } + for (const [userId, devices] of Object.entries(devicesByUser)) { result[userId] = {}; for (const deviceInfo of devices) { @@ -216,7 +255,7 @@ export async function ensureOlmSessionsForDevices( // new chain when this side has an active sender chain. // If you see this message being logged in the wild, we should find // the thing that is trying to send Olm messages to itself and fix it. - logger.info("Attempted to start session with ourself! Ignoring"); + log.info("Attempted to start session with ourself! Ignoring"); // We must fill in the section in the return value though, as callers // expect it to be there. result[userId][deviceId] = { @@ -226,41 +265,23 @@ export async function ensureOlmSessionsForDevices( continue; } - if (!olmDevice._sessionsInProgress[key]) { - // pre-emptively mark the session as in-progress to avoid race - // conditions. If we find that we already have a session, then - // we'll resolve - olmDevice._sessionsInProgress[key] = new Promise( - (resolve, reject) => { - resolveSession[key] = { - resolve: (...args) => { - delete olmDevice._sessionsInProgress[key]; - resolve(...args); - }, - reject: (...args) => { - delete olmDevice._sessionsInProgress[key]; - reject(...args); - }, - }; - }, - ); - } + const forWhom = `for ${key} (${userId}:${deviceId})`; + log.debug(`Ensuring Olm session ${forWhom}`); const sessionId = await olmDevice.getSessionIdForDevice( - key, resolveSession[key], + key, resolveSession[key], log, ); + log.debug(`Got Olm session ${sessionId} ${forWhom}`); if (sessionId !== null && resolveSession[key]) { // we found a session, but we had marked the session as - // in-progress, so unmark it and unblock anything that was - // waiting - delete olmDevice._sessionsInProgress[key]; - resolveSession[key].resolve(); - delete resolveSession[key]; + // in-progress, so resolve it now, which will unmark it and + // unblock anything that was waiting + resolveSession[key](); } if (sessionId === null || force) { if (force) { - logger.info("Forcing new Olm session for " + userId + ":" + deviceId); + log.info(`Forcing new Olm session ${forWhom}`); } else { - logger.info("Making new Olm session for " + userId + ":" + deviceId); + log.info(`Making new Olm session ${forWhom}`); } devicesWithoutSession.push([userId, deviceId]); } @@ -283,20 +304,24 @@ export async function ensureOlmSessionsForDevices( // timeout on this request, let's first log whether that's the root // cause we're seeing in practice. // See also https://github.com/vector-im/element-web/issues/16194 - const otkTimeoutLogger = setTimeout(() => { - logger.error(`Homeserver never replied while claiming ${taskDetail}`); - }, otkTimeout); + let otkTimeoutLogger; + // XXX: Perhaps there should be a default timeout? + if (otkTimeout) { + otkTimeoutLogger = setTimeout(() => { + log.error(`Homeserver never replied while claiming ${taskDetail}`); + }, otkTimeout); + } try { - logger.debug(`Claiming ${taskDetail}`); + log.debug(`Claiming ${taskDetail}`); res = await baseApis.claimOneTimeKeys( devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout, ); - logger.debug(`Claimed ${taskDetail}`); + log.debug(`Claimed ${taskDetail}`); } catch (e) { for (const resolver of Object.values(resolveSession)) { - resolver.resolve(); + resolver(); } - logger.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession); + log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession); throw e; } finally { clearTimeout(otkTimeoutLogger); @@ -306,10 +331,10 @@ export async function ensureOlmSessionsForDevices( failedServers.push(...Object.keys(res.failures)); } - const otk_res = res.one_time_keys || {}; + const otkResult = res.one_time_keys || {}; const promises = []; for (const [userId, devices] of Object.entries(devicesByUser)) { - const userRes = otk_res[userId] || {}; + const userRes = otkResult[userId] || {}; for (let j = 0; j < devices.length; j++) { const deviceInfo = devices[j]; const deviceId = deviceInfo.deviceId; @@ -336,11 +361,12 @@ export async function ensureOlmSessionsForDevices( } if (!oneTimeKey) { - const msg = "No one-time keys (alg=" + oneTimeKeyAlgorithm + - ") for device " + userId + ":" + deviceId; - logger.warn(msg); + log.warn( + `No one-time keys (alg=${oneTimeKeyAlgorithm}) ` + + `for device ${userId}:${deviceId}`, + ); if (resolveSession[key]) { - resolveSession[key].resolve(); + resolveSession[key](); } continue; } @@ -350,12 +376,12 @@ export async function ensureOlmSessionsForDevices( olmDevice, oneTimeKey, userId, deviceInfo, ).then((sid) => { if (resolveSession[key]) { - resolveSession[key].resolve(sid); + resolveSession[key](sid); } result[userId][deviceId].sessionId = sid; }, (e) => { if (resolveSession[key]) { - resolveSession[key].resolve(); + resolveSession[key](); } throw e; }), @@ -364,9 +390,9 @@ export async function ensureOlmSessionsForDevices( } taskDetail = `Olm sessions for ${promises.length} devices`; - logger.debug(`Starting ${taskDetail}`); + log.debug(`Starting ${taskDetail}`); await Promise.all(promises); - logger.debug(`Started ${taskDetail}`); + log.debug(`Started ${taskDetail}`); return result; } diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index 505e5fe51..6ecffe7be 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.js +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -34,6 +34,7 @@ export class Backend { */ constructor(db) { this._db = db; + this._nextTxnId = 0; // make sure we close the db on `onversionchange` - otherwise // attempts to delete the database will block (and subsequent @@ -757,10 +758,21 @@ export class Backend { })); } - doTxn(mode, stores, func) { + doTxn(mode, stores, func, log = logger) { + const txnId = this._nextTxnId++; + const startTime = Date.now(); + const description = `${mode} crypto store transaction ${txnId} in ${stores}`; + log.debug(`Starting ${description}`); const txn = this._db.transaction(stores, mode); const promise = promiseifyTxn(txn); const result = func(txn); + promise.then(() => { + const elapsedTime = Date.now() - startTime; + log.debug(`Finished ${description}, took ${elapsedTime} ms`); + }, () => { + const elapsedTime = Date.now() - startTime; + log.error(`Failed ${description}, took ${elapsedTime} ms`); + }); return promise.then(() => { return result; }); diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index 3dc49858c..50f3c2678 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -596,6 +596,7 @@ export class IndexedDBCryptoStore { * @param {function(*)} func Function called with the * transaction object: an opaque object that should be passed * to store functions. + * @param {Logger} [log] A possibly customised log * @return {Promise} Promise that resolves with the result of the `func` * when the transaction is complete. If the backend is * async (ie. the indexeddb backend) any of the callback @@ -603,8 +604,8 @@ export class IndexedDBCryptoStore { * reject with that exception. On synchronous backends, the * exception will propagate to the caller of the getFoo method. */ - doTxn(mode, stores, func) { - return this._backend.doTxn(mode, stores, func); + doTxn(mode, stores, func, log) { + return this._backend.doTxn(mode, stores, func, log); } } diff --git a/src/logger.ts b/src/logger.ts index fc5ceeb4d..e5fced726 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,6 @@ /* Copyright 2018 André Jaenisch -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,7 +19,7 @@ limitations under the License. * @module logger */ -import log from "loglevel"; +import log, { Logger } from "loglevel"; // This is to demonstrate, that you can use any namespace you want. // Namespaces allow you to turn on/off the logging for specific parts of the @@ -36,6 +36,11 @@ const DEFAULT_NAMESPACE = "matrix"; // when logging so we always get the current value of console methods. log.methodFactory = function(methodName, logLevel, loggerName) { return function(...args) { + /* eslint-disable babel/no-invalid-this */ + if (this.prefix) { + args.unshift(this.prefix); + } + /* eslint-enable babel/no-invalid-this */ const supportedByConsole = methodName === "error" || methodName === "warn" || methodName === "trace" || @@ -54,6 +59,30 @@ log.methodFactory = function(methodName, logLevel, loggerName) { * Drop-in replacement for console using {@link https://www.npmjs.com/package/loglevel|loglevel}. * Can be tailored down to specific use cases if needed. */ -export const logger = log.getLogger(DEFAULT_NAMESPACE); +export const logger: PrefixedLogger = log.getLogger(DEFAULT_NAMESPACE); logger.setLevel(log.levels.DEBUG); +interface PrefixedLogger extends Logger { + withPrefix?: (prefix: string) => PrefixedLogger; + prefix?: string; +} + +function extendLogger(logger: PrefixedLogger) { + logger.withPrefix = function(prefix: string): PrefixedLogger { + const existingPrefix = this.prefix || ""; + return getPrefixedLogger(existingPrefix + prefix); + }; +} + +extendLogger(logger); + +function getPrefixedLogger(prefix): PrefixedLogger { + const prefixLogger: PrefixedLogger = log.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`); + if (prefixLogger.prefix !== prefix) { + // Only do this setup work the first time through, as loggers are saved by name. + extendLogger(prefixLogger); + prefixLogger.prefix = prefix; + prefixLogger.setLevel(log.levels.DEBUG); + } + return prefixLogger; +} diff --git a/src/matrix.ts b/src/matrix.ts index cffda610c..f1014c5de 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -52,7 +52,7 @@ export * from "./store/session/webstorage"; export * from "./crypto/store/memory-crypto-store"; export * from "./crypto/store/indexeddb-crypto-store"; export * from "./content-repo"; -export const ContentHelpers = import("./content-helpers"); +export * as ContentHelpers from "./content-helpers"; export { createNewMatrixCall, setAudioOutput as setMatrixCallAudioOutput, diff --git a/src/models/room-member.js b/src/models/room-member.js index 0ba6edd60..444c45609 100644 --- a/src/models/room-member.js +++ b/src/models/room-member.js @@ -290,6 +290,9 @@ RoomMember.prototype.getMxcAvatarUrl = function() { return null; }; +const MXID_PATTERN = /@.+:.+/; +const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; + function calculateDisplayName(selfUserId, displayName, roomState) { if (!displayName || displayName === selfUserId) { return selfUserId; @@ -308,13 +311,13 @@ function calculateDisplayName(selfUserId, displayName, roomState) { // Next check if the name contains something that look like a mxid // If it does, it may be someone trying to impersonate someone else // Show full mxid in this case - let disambiguate = /@.+:.+/.test(displayName); + let disambiguate = MXID_PATTERN.test(displayName); if (!disambiguate) { // Also show mxid if the display name contains any LTR/RTL characters as these // make it very difficult for us to find similar *looking* display names // E.g "Mark" could be cloned by writing "kraM" but in RTL. - disambiguate = /[\u200E\u200F\u202A-\u202F]/.test(displayName); + disambiguate = LTR_RTL_PATTERN.test(displayName); } if (!disambiguate) { diff --git a/src/models/room.js b/src/models/room.js index 047d7c5f4..9c321e1bb 100644 --- a/src/models/room.js +++ b/src/models/room.js @@ -30,7 +30,7 @@ import {RoomMember} from "./room-member"; import {RoomSummary} from "./room-summary"; import {logger} from '../logger'; import {ReEmitter} from '../ReEmitter'; -import {EventType} from "../@types/event"; +import {EventType, RoomCreateTypeField, RoomType} from "../@types/event"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be @@ -1856,6 +1856,27 @@ Room.prototype.getJoinRule = function() { return this.currentState.getJoinRule(); }; +/** + * Returns the type of the room from the `m.room.create` event content or undefined if none is set + * @returns {?string} the type of the room. Currently only RoomType.Space is known. + */ +Room.prototype.getType = function() { + const createEvent = this.currentState.getStateEvents("m.room.create", ""); + if (!createEvent) { + logger.warn("Room " + this.roomId + " does not have an m.room.create event"); + return undefined; + } + return createEvent.getContent()[RoomCreateTypeField]; +}; + +/** + * Returns whether the room is a space-room as defined by MSC1772. + * @returns {boolean} true if the room's type is RoomType.Space + */ +Room.prototype.isSpaceRoom = function() { + return this.getType() === RoomType.Space; +}; + /** * This is an internal method. Calculates the name of the room from the current * room state. diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 2a74d3736..7e6de3cd8 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -257,8 +257,6 @@ export class MatrixCall extends EventEmitter { private localAVStream: MediaStream; private inviteOrAnswerSent: boolean; private waitForLocalAVStream: boolean; - // XXX: This is either the invite or answer from remote... - private msg: any; // XXX: I don't know why this is called 'config'. private config: MediaStreamConstraints; private successor: MatrixCall; @@ -290,6 +288,11 @@ export class MatrixCall extends EventEmitter { private makingOffer: boolean; private ignoreOffer: boolean; + // If candidates arrive before we've picked an opponent (which, in particular, + // will happen if the opponent sends candidates eagerly before the user answers + // the call) we buffer them up here so we can then add the ones from the party we pick + private remoteCandidateBuffer = new Map(); + constructor(opts: CallOpts) { super(); this.roomId = opts.roomId; @@ -297,9 +300,6 @@ export class MatrixCall extends EventEmitter { this.type = null; this.forceTURN = opts.forceTURN; this.ourPartyId = this.client.deviceId; - // We compare this to null to checks the presence of a party ID: - // make sure it's null, not undefined - this.opponentPartyId = null; // Array of Objects with urls, username, credential keys this.turnServers = opts.turnServers || []; if (this.turnServers.length === 0 && this.client.isFallbackICEServerAllowed()) { @@ -524,11 +524,16 @@ export class MatrixCall extends EventEmitter { * @param {MatrixEvent} event The m.call.invite event */ async initWithInvite(event: MatrixEvent) { - this.msg = event.getContent(); + const invite = event.getContent(); this.direction = CallDirection.Inbound; + this.peerConn = this.createPeerConnection(); + // we must set the party ID before await-ing on anything: the call event + // handler will start giving us more call events (eg. candidates) so if + // we haven't set the party ID, we'll ignore them. + this.chooseOpponent(event); try { - await this.peerConn.setRemoteDescription(this.msg.offer); + await this.peerConn.setRemoteDescription(invite.offer); } catch (e) { logger.debug("Failed to set remote description", e); this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false); @@ -547,13 +552,6 @@ export class MatrixCall extends EventEmitter { this.type = this.remoteStream.getTracks().some(t => t.kind === 'video') ? CallType.Video : CallType.Voice; this.setState(CallState.Ringing); - this.opponentVersion = this.msg.version; - if (this.opponentVersion !== 0) { - // ignore party ID in v0 calls: party ID isn't a thing until v1 - this.opponentPartyId = this.msg.party_id || null; - } - this.opponentCaps = this.msg.capabilities || {}; - this.opponentMember = event.sender; if (event.getLocalAge()) { setTimeout(() => { @@ -567,7 +565,7 @@ export class MatrixCall extends EventEmitter { } this.emit(CallEvent.Hangup); } - }, this.msg.lifetime - event.getLocalAge()); + }, invite.lifetime - event.getLocalAge()); } } @@ -579,7 +577,6 @@ export class MatrixCall extends EventEmitter { // perverse as it may seem, sometimes we want to instantiate a call with a // hangup message (because when getting the state of the room on load, events // come in reverse order and we want to remember that a call has been hung up) - this.msg = event.getContent(); this.setState(CallState.Ended); } @@ -873,7 +870,7 @@ export class MatrixCall extends EventEmitter { // Now we wait for the negotiationneeded event }; - private sendAnswer() { + private async sendAnswer() { const answerContent = { answer: { sdp: this.peerConn.localDescription.sdp, @@ -895,12 +892,12 @@ export class MatrixCall extends EventEmitter { logger.info(`Discarding ${this.candidateSendQueue.length} candidates that will be sent in answer`); this.candidateSendQueue = []; - this.sendVoipEvent(EventType.CallAnswer, answerContent).then(() => { + try { + await this.sendVoipEvent(EventType.CallAnswer, answerContent); // If this isn't the first time we've tried to send the answer, // we may have candidates queued up, so send them now. this.inviteOrAnswerSent = true; - this.sendCandidateQueue(); - }).catch((error) => { + } catch (error) { // We've failed to answer: back to the ringing state this.setState(CallState.Ringing); this.client.cancelPendingEvent(error.event); @@ -913,7 +910,11 @@ export class MatrixCall extends EventEmitter { } this.emit(CallEvent.Error, new CallError(code, message, error)); throw error; - }); + } + + // error handler re-throws so this won't happen on error, but + // we don't want the same error handling on the candidate queue + this.sendCandidateQueue(); } private gotUserMediaForAnswer = async (stream: MediaStream) => { @@ -1017,37 +1018,33 @@ export class MatrixCall extends EventEmitter { return; } - if (!this.partyIdMatches(ev.getContent())) { - logger.info( - `Ignoring candidates from party ID ${ev.getContent().party_id}: ` + - `we have chosen party ID ${this.opponentPartyId}`, - ); - return; - } - const cands = ev.getContent().candidates; if (!cands) { logger.info("Ignoring candidates event with no candidates!"); return; } - for (const cand of cands) { - if ( - (cand.sdpMid === null || cand.sdpMid === undefined) && - (cand.sdpMLineIndex === null || cand.sdpMLineIndex === undefined) - ) { - logger.debug("Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex"); - return; - } - logger.debug("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate); - try { - this.peerConn.addIceCandidate(cand); - } catch (err) { - if (!this.ignoreOffer) { - logger.info("Failed to add remore ICE candidate", err); - } - } + const fromPartyId = ev.getContent().version === 0 ? null : ev.getContent().party_id || null; + + if (this.opponentPartyId === undefined) { + // we haven't picked an opponent yet so save the candidates + logger.info(`Bufferring ${cands.length} candidates until we pick an opponent`); + const bufferedCands = this.remoteCandidateBuffer.get(fromPartyId) || []; + bufferedCands.push(...cands); + this.remoteCandidateBuffer.set(fromPartyId, bufferedCands); + return; } + + if (!this.partyIdMatches(ev.getContent())) { + logger.info( + `Ignoring candidates from party ID ${ev.getContent().party_id}: ` + + `we have chosen party ID ${this.opponentPartyId}`, + ); + + return; + } + + this.addIceCandidates(cands); } /** @@ -1059,7 +1056,7 @@ export class MatrixCall extends EventEmitter { return; } - if (this.opponentPartyId !== null) { + if (this.opponentPartyId !== undefined) { logger.info( `Ignoring answer from party ID ${event.getContent().party_id}: ` + `we already have an answer/reject from ${this.opponentPartyId}`, @@ -1067,12 +1064,7 @@ export class MatrixCall extends EventEmitter { return; } - this.opponentVersion = event.getContent().version; - if (this.opponentVersion !== 0) { - this.opponentPartyId = event.getContent().party_id || null; - } - this.opponentCaps = event.getContent().capabilities || {}; - this.opponentMember = event.sender; + this.chooseOpponent(event); this.setState(CallState.Connecting); @@ -1247,19 +1239,9 @@ export class MatrixCall extends EventEmitter { try { await this.sendVoipEvent(eventType, content); - this.sendCandidateQueue(); - if (this.state === CallState.CreateOffer) { - this.inviteOrAnswerSent = true; - this.setState(CallState.InviteSent); - this.inviteTimeout = setTimeout(() => { - this.inviteTimeout = null; - if (this.state === CallState.InviteSent) { - this.hangup(CallErrorCode.InviteTimeout, false); - } - }, CALL_TIMEOUT_MS); - } } catch (error) { - this.client.cancelPendingEvent(error.event); + logger.error("Failed to send invite", error); + if (error.event) this.client.cancelPendingEvent(error.event); let code = CallErrorCode.SignallingFailed; let message = "Signalling failed"; @@ -1274,6 +1256,22 @@ export class MatrixCall extends EventEmitter { this.emit(CallEvent.Error, new CallError(code, message, error)); this.terminate(CallParty.Local, code, false); + + // no need to carry on & send the candidate queue, but we also + // don't want to rethrow the error + return; + } + + this.sendCandidateQueue(); + if (this.state === CallState.CreateOffer) { + this.inviteOrAnswerSent = true; + this.setState(CallState.InviteSent); + this.inviteTimeout = setTimeout(() => { + this.inviteTimeout = null; + if (this.state === CallState.InviteSent) { + this.hangup(CallErrorCode.InviteTimeout, false); + } + }, CALL_TIMEOUT_MS); } }; @@ -1610,7 +1608,7 @@ export class MatrixCall extends EventEmitter { } } - private sendCandidateQueue() { + private async sendCandidateQueue() { if (this.candidateSendQueue.length === 0) { return; } @@ -1622,20 +1620,28 @@ export class MatrixCall extends EventEmitter { candidates: cands, }; logger.debug("Attempting to send " + cands.length + " candidates"); - this.sendVoipEvent(EventType.CallCandidates, content).then(() => { - this.candidateSendTries = 0; - this.sendCandidateQueue(); - }, (error) => { - for (let i = 0; i < cands.length; i++) { - this.candidateSendQueue.push(cands[i]); - } + try { + await this.sendVoipEvent(EventType.CallCandidates, content); + } catch (error) { + // don't retry this event: we'll send another one later as we might + // have more candidates by then. + if (error.event) this.client.cancelPendingEvent(error.event); + + // put all the candidates we failed to send back in the queue + this.candidateSendQueue.push(...cands); if (this.candidateSendTries > 5) { logger.debug( "Failed to send candidates on attempt " + this.candidateSendTries + - ". Giving up for now.", error, + ". Giving up on this call.", error, ); - this.candidateSendTries = 0; + + const code = CallErrorCode.SignallingFailed; + const message = "Signalling failed"; + + this.emit(CallEvent.Error, new CallError(code, message, error)); + this.hangup(code, false); + return; } @@ -1645,7 +1651,7 @@ export class MatrixCall extends EventEmitter { setTimeout(() => { this.sendCandidateQueue(); }, delayMs); - }); + } } private async placeCallWithConstraints(constraints: MediaStreamConstraints) { @@ -1689,9 +1695,60 @@ export class MatrixCall extends EventEmitter { private partyIdMatches(msg): boolean { // They must either match or both be absent (in which case opponentPartyId will be null) - const msgPartyId = msg.party_id || null; + // Also we ignore party IDs on the invite/offer if the version is 0, so we must do the same + // here and use null if the version is 0 (woe betide any opponent sending messages in the + // same call with different versions) + const msgPartyId = msg.version === 0 ? null : msg.party_id || null; return msgPartyId === this.opponentPartyId; } + + // Commits to an opponent for the call + // ev: An invite or answer event + private chooseOpponent(ev: MatrixEvent) { + // I choo-choo-choose you + const msg = ev.getContent(); + + this.opponentVersion = msg.version; + if (this.opponentVersion === 0) { + // set to null to indicate that we've chosen an opponent, but because + // they're v0 they have no party ID (even if they sent one, we're ignoring it) + this.opponentPartyId = null; + } else { + // set to their party ID, or if they're naughty and didn't send one despite + // not being v0, set it to null to indicate we picked an opponent with no + // party ID + this.opponentPartyId = msg.party_id || null; + } + this.opponentCaps = msg.capabilities || {}; + this.opponentMember = ev.sender; + + const bufferedCands = this.remoteCandidateBuffer.get(this.opponentPartyId); + if (bufferedCands) { + logger.info(`Adding ${bufferedCands.length} buffered candidates for opponent ${this.opponentPartyId}`); + this.addIceCandidates(bufferedCands); + } + this.remoteCandidateBuffer = null; + } + + private addIceCandidates(cands: RTCIceCandidate[]) { + for (const cand of cands) { + if ( + (cand.sdpMid === null || cand.sdpMid === undefined) && + (cand.sdpMLineIndex === null || cand.sdpMLineIndex === undefined) + ) { + logger.debug("Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex"); + return; + } + logger.debug("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate); + try { + this.peerConn.addIceCandidate(cand); + } catch (err) { + if (!this.ignoreOffer) { + logger.info("Failed to add remore ICE candidate", err); + } + } + } + } } function setTracksEnabled(tracks: Array, enabled: boolean) { diff --git a/src/webrtc/callEventHandler.ts b/src/webrtc/callEventHandler.ts index ba4452ea8..304eacfdd 100644 --- a/src/webrtc/callEventHandler.ts +++ b/src/webrtc/callEventHandler.ts @@ -138,6 +138,8 @@ export class CallEventHandler { ); } + const timeUntilTurnCresExpire = this.client.getTurnServersExpiry() - Date.now(); + logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " seconds"); call = createNewMatrixCall(this.client, event.getRoomId(), { forceTURN: this.client._forceTURN, }); diff --git a/yarn.lock b/yarn.lock index a47f6eb4e..d3a09a364 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5069,11 +5069,16 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4: +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20: version "4.17.20" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@^4.17.4: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + loglevel@^1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197" @@ -5917,9 +5922,9 @@ pug-attrs@^2.0.4: pug-runtime "^2.0.5" pug-code-gen@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/pug-code-gen/-/pug-code-gen-2.0.2.tgz#ad0967162aea077dcf787838d94ed14acb0217c2" - integrity sha512-kROFWv/AHx/9CRgoGJeRSm+4mLWchbgpRzTEn8XCiwwOy6Vh0gAClS8Vh5TEJ9DBjaP8wCjS3J6HKsEsYdvaCw== + version "2.0.3" + resolved "https://registry.yarnpkg.com/pug-code-gen/-/pug-code-gen-2.0.3.tgz#122eb9ada9b5bf601705fe15aaa0a7d26bc134ab" + integrity sha512-r9sezXdDuZJfW9J91TN/2LFbiqDhmltTFmGpHTsGdrNGp3p4SxAjjXEfnuK2e4ywYsRIVP0NeLbSAMHUcaX1EA== dependencies: constantinople "^3.1.2" doctypes "^1.1.0"