Compare commits
110 Commits
v11.2.0-rc.1
...
v12.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 467b75f2dc | |||
| 1f728cab92 | |||
| 68c1171294 | |||
| f4f01913fe | |||
| 9f6ed4fb33 | |||
| 434117c771 | |||
| cd95b8724a | |||
| ec2a4d473e | |||
| 47eaf3cd58 | |||
| d7b23a8634 | |||
| 6db7972f04 | |||
| 6840ee077c | |||
| 3c85bcc3c9 | |||
| 759eca6479 | |||
| 4488f174aa | |||
| 991a255041 | |||
| cfef635e1b | |||
| d3027e1fe8 | |||
| d99ea1c6b4 | |||
| 9af214007e | |||
| 0541b7f3c5 | |||
| 63fa774af7 | |||
| 4b19b36de1 | |||
| 5715df6b18 | |||
| 22bcacc715 | |||
| f1e270ca9d | |||
| f535e7535c | |||
| 912c5e13e1 | |||
| 4eb44ee2ea | |||
| e41a2beb65 | |||
| 1f6ba31a3f | |||
| bcccc909c5 | |||
| b3a11030f2 | |||
| baaf76668f | |||
| 36e2533164 | |||
| 9084b4e7aa | |||
| f724da7e84 | |||
| 4b8f47e2b4 | |||
| 35ecbed29d | |||
| 30ed153ad1 | |||
| a1098989ff | |||
| 42bb63e07d | |||
| 683aae5ed5 | |||
| 49ef274a83 | |||
| af16bba1ec | |||
| 781086608e | |||
| e9c98b03b0 | |||
| 75370f7855 | |||
| 4a79e13410 | |||
| 220061f022 | |||
| 6c317b7f28 | |||
| 3788fbf607 | |||
| 382854c04c | |||
| c2fae3bad8 | |||
| 92ebd39391 | |||
| f53a32a6b4 | |||
| 6c882e6605 | |||
| ca85dfc6ff | |||
| e22ecc6b6d | |||
| 2608dd2d64 | |||
| 7d20d24249 | |||
| a3ac3692af | |||
| 0070c8f843 | |||
| b360fc8308 | |||
| bf9ba65ac4 | |||
| 43abfbc537 | |||
| 2700f0acf6 | |||
| 9156bed961 | |||
| 71dc0bac56 | |||
| 40f55b2964 | |||
| 9307f9f345 | |||
| dfb918adc3 | |||
| 2f87a4859e | |||
| 191f73e0d0 | |||
| 263e55f25d | |||
| 5c55dce13e | |||
| a1a6ec6dfa | |||
| e1edd84700 | |||
| 07ee256756 | |||
| 48888e530e | |||
| 4ef50bef55 | |||
| 486369e97c | |||
| 67994f7a53 | |||
| f027ddaf35 | |||
| f3b27d1e06 | |||
| 92e18b32dc | |||
| 4030ec9c8b | |||
| 497c2dc8df | |||
| 8a1d34c419 | |||
| caab5befaa | |||
| 0d316e3d3e | |||
| 8f9a682d34 | |||
| 102d5acf09 | |||
| e2ec8952e3 | |||
| b4eff9b996 | |||
| c543358826 | |||
| 07bfa5532e | |||
| e73b969066 | |||
| 98e2154f0f | |||
| 3ec89a89df | |||
| 9e6b72bf38 | |||
| 747723c8fb | |||
| e4fbbd56a9 | |||
| 4290e8e56b | |||
| a416fd562b | |||
| 323a096dd7 | |||
| 628dd7bf41 | |||
| 71c6d71cae | |||
| e049edd449 | |||
| 0fdfc3ff53 |
@@ -1,3 +1,47 @@
|
||||
Changes in [12.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.0) (2021-06-21)
|
||||
==================================================================================================
|
||||
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v12.0.0-rc.1...v12.0.0)
|
||||
|
||||
* No changes since rc.1
|
||||
|
||||
Changes in [12.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.0-rc.1) (2021-06-15)
|
||||
============================================================================================================
|
||||
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v11.2.0...v12.0.0-rc.1)
|
||||
|
||||
* Rework how disambiguation is handled
|
||||
[\#1730](https://github.com/matrix-org/matrix-js-sdk/pull/1730)
|
||||
* Fix baseToString for n=0 edge case to match inverse stringToBase
|
||||
[\#1735](https://github.com/matrix-org/matrix-js-sdk/pull/1735)
|
||||
* Move various types from the react-sdk to the js-sdk
|
||||
[\#1734](https://github.com/matrix-org/matrix-js-sdk/pull/1734)
|
||||
* Unstable implementation of MSC3089: File Trees
|
||||
[\#1732](https://github.com/matrix-org/matrix-js-sdk/pull/1732)
|
||||
* Add MSC3230 event type to enum
|
||||
[\#1729](https://github.com/matrix-org/matrix-js-sdk/pull/1729)
|
||||
* Add separate reason code for transferred calls
|
||||
[\#1731](https://github.com/matrix-org/matrix-js-sdk/pull/1731)
|
||||
* Use sendonly for call hold
|
||||
[\#1728](https://github.com/matrix-org/matrix-js-sdk/pull/1728)
|
||||
* Stop breeding sync listeners
|
||||
[\#1727](https://github.com/matrix-org/matrix-js-sdk/pull/1727)
|
||||
* Fix semicolons in TS files
|
||||
[\#1724](https://github.com/matrix-org/matrix-js-sdk/pull/1724)
|
||||
* [BREAKING] Convert MatrixClient to TypeScript
|
||||
[\#1718](https://github.com/matrix-org/matrix-js-sdk/pull/1718)
|
||||
* Factor out backup management to a separate module
|
||||
[\#1697](https://github.com/matrix-org/matrix-js-sdk/pull/1697)
|
||||
* Ignore power_levels events with unknown state_key on room-state
|
||||
initialization
|
||||
[\#1723](https://github.com/matrix-org/matrix-js-sdk/pull/1723)
|
||||
* Revert 1579 (Fix extra negotiate message in Firefox)
|
||||
[\#1725](https://github.com/matrix-org/matrix-js-sdk/pull/1725)
|
||||
|
||||
Changes in [11.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v11.2.0) (2021-06-07)
|
||||
==================================================================================================
|
||||
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v11.2.0-rc.1...v11.2.0)
|
||||
|
||||
* No changes since rc.1
|
||||
|
||||
Changes in [11.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v11.2.0-rc.1) (2021-06-01)
|
||||
============================================================================================================
|
||||
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v11.1.0...v11.2.0-rc.1)
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "11.2.0-rc.1",
|
||||
"version": "12.0.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 72 src spec",
|
||||
"lint:js": "eslint --max-warnings 57 src spec",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export class MockBlob {
|
||||
private contents: number[] = [];
|
||||
|
||||
public constructor(private parts: ArrayLike<number>[]) {
|
||||
parts.forEach(p => Array.from(p).forEach(e => this.contents.push(e)));
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.contents.length;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -69,7 +69,7 @@ export function TestClient(
|
||||
|
||||
this.deviceKeys = null;
|
||||
this.oneTimeKeys = {};
|
||||
this._callEventHandler = {
|
||||
this.callEventHandler = {
|
||||
calls: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ describe("DeviceList management:", function() {
|
||||
aliceTestClient.httpBackend.flush('/keys/query', 1).then(
|
||||
() => aliceTestClient.httpBackend.flush('/send/', 1),
|
||||
),
|
||||
aliceTestClient.client._crypto._deviceList.saveIfDirty(),
|
||||
aliceTestClient.client.crypto._deviceList.saveIfDirty(),
|
||||
]);
|
||||
}).then(() => {
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
@@ -202,7 +202,7 @@ describe("DeviceList management:", function() {
|
||||
return aliceTestClient.httpBackend.flush('/keys/query', 1);
|
||||
}).then((flushed) => {
|
||||
expect(flushed).toEqual(0);
|
||||
return aliceTestClient.client._crypto._deviceList.saveIfDirty();
|
||||
return aliceTestClient.client.crypto._deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
@@ -235,7 +235,7 @@ describe("DeviceList management:", function() {
|
||||
// wait for the client to stop processing the response
|
||||
return aliceTestClient.client.downloadKeys(['@bob:xyz']);
|
||||
}).then(() => {
|
||||
return aliceTestClient.client._crypto._deviceList.saveIfDirty();
|
||||
return aliceTestClient.client.crypto._deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
@@ -256,7 +256,7 @@ describe("DeviceList management:", function() {
|
||||
// wait for the client to stop processing the response
|
||||
return aliceTestClient.client.downloadKeys(['@chris:abc']);
|
||||
}).then(() => {
|
||||
return aliceTestClient.client._crypto._deviceList.saveIfDirty();
|
||||
return aliceTestClient.client.crypto._deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
@@ -286,7 +286,7 @@ describe("DeviceList management:", function() {
|
||||
},
|
||||
);
|
||||
await aliceTestClient.httpBackend.flush('/keys/query', 1);
|
||||
await aliceTestClient.client._crypto._deviceList.saveIfDirty();
|
||||
await aliceTestClient.client.crypto._deviceList.saveIfDirty();
|
||||
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
@@ -322,7 +322,7 @@ describe("DeviceList management:", function() {
|
||||
);
|
||||
|
||||
await aliceTestClient.flushSync();
|
||||
await aliceTestClient.client._crypto._deviceList.saveIfDirty();
|
||||
await aliceTestClient.client.crypto._deviceList.saveIfDirty();
|
||||
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
@@ -358,7 +358,7 @@ describe("DeviceList management:", function() {
|
||||
);
|
||||
|
||||
await aliceTestClient.flushSync();
|
||||
await aliceTestClient.client._crypto._deviceList.saveIfDirty();
|
||||
await aliceTestClient.client.crypto._deviceList.saveIfDirty();
|
||||
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
@@ -379,7 +379,7 @@ describe("DeviceList management:", function() {
|
||||
anotherTestClient.httpBackend.when('GET', '/sync').respond(
|
||||
200, getSyncResponse([]));
|
||||
await anotherTestClient.flushSync();
|
||||
await anotherTestClient.client._crypto._deviceList.saveIfDirty();
|
||||
await anotherTestClient.client.crypto._deviceList.saveIfDirty();
|
||||
|
||||
anotherTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
|
||||
@@ -159,7 +159,7 @@ function aliDownloadsKeys() {
|
||||
// check that the localStorage is updated as we expect (not sure this is
|
||||
// an integration test, but meh)
|
||||
return Promise.all([p1, p2]).then(() => {
|
||||
return aliTestClient.client._crypto._deviceList.saveIfDirty();
|
||||
return aliTestClient.client.crypto._deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const devices = data.devices[bobUserId];
|
||||
|
||||
@@ -336,7 +336,7 @@ describe("MatrixClient", function() {
|
||||
var b = JSON.parse(JSON.stringify(o));
|
||||
delete(b.signatures);
|
||||
delete(b.unsigned);
|
||||
return client._crypto._olmDevice.sign(anotherjson.stringify(b));
|
||||
return client.crypto._olmDevice.sign(anotherjson.stringify(b));
|
||||
};
|
||||
|
||||
logger.log("Ed25519: " + ed25519key);
|
||||
|
||||
@@ -998,7 +998,7 @@ describe("megolm", function() {
|
||||
...rawEvent,
|
||||
room: ROOM_ID,
|
||||
});
|
||||
return event.attemptDecryption(testClient.client._crypto, true).then(() => {
|
||||
return event.attemptDecryption(testClient.client.crypto, true).then(() => {
|
||||
expect(event.isKeySourceUntrusted()).toBeTruthy();
|
||||
});
|
||||
}).then(() => {
|
||||
@@ -1013,14 +1013,14 @@ describe("megolm", function() {
|
||||
event: true,
|
||||
});
|
||||
event._senderCurve25519Key = testSenderKey;
|
||||
return testClient.client._crypto._onRoomKeyEvent(event);
|
||||
return testClient.client.crypto._onRoomKeyEvent(event);
|
||||
}).then(() => {
|
||||
const event = testUtils.mkEvent({
|
||||
event: true,
|
||||
...rawEvent,
|
||||
room: ROOM_ID,
|
||||
});
|
||||
return event.attemptDecryption(testClient.client._crypto, true).then(() => {
|
||||
return event.attemptDecryption(testClient.client.crypto, true).then(() => {
|
||||
expect(event.isKeySourceUntrusted()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -357,12 +357,12 @@ export function setHttpResponses(
|
||||
);
|
||||
|
||||
const httpReq = httpResponseObj.request.bind(httpResponseObj);
|
||||
client._http = [
|
||||
client.http = [
|
||||
"authedRequest", "authedRequestWithPrefix", "getContentUri",
|
||||
"request", "requestWithPrefix", "uploadContent",
|
||||
].reduce((r, k) => {r[k] = jest.fn(); return r;}, {});
|
||||
client._http.authedRequest.mockImplementation(httpReq);
|
||||
client._http.authedRequestWithPrefix.mockImplementation(httpReq);
|
||||
client._http.requestWithPrefix.mockImplementation(httpReq);
|
||||
client._http.request.mockImplementation(httpReq);
|
||||
client.http.authedRequest.mockImplementation(httpReq);
|
||||
client.http.authedRequestWithPrefix.mockImplementation(httpReq);
|
||||
client.http.requestWithPrefix.mockImplementation(httpReq);
|
||||
client.http.request.mockImplementation(httpReq);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { NamespacedValue, UnstableValue } from "../../src/NamespacedValue";
|
||||
|
||||
describe("NamespacedValue", () => {
|
||||
it("should prefer stable over unstable", () => {
|
||||
const ns = new NamespacedValue("stable", "unstable");
|
||||
expect(ns.name).toBe(ns.stable);
|
||||
expect(ns.altName).toBe(ns.unstable);
|
||||
});
|
||||
|
||||
it("should return unstable if there is no stable", () => {
|
||||
const ns = new NamespacedValue(null, "unstable");
|
||||
expect(ns.name).toBe(ns.unstable);
|
||||
expect(ns.altName).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should have a falsey unstable if needed", () => {
|
||||
const ns = new NamespacedValue("stable", null);
|
||||
expect(ns.name).toBe(ns.stable);
|
||||
expect(ns.altName).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should match against either stable or unstable", () => {
|
||||
const ns = new NamespacedValue("stable", "unstable");
|
||||
expect(ns.matches("no")).toBe(false);
|
||||
expect(ns.matches(ns.stable)).toBe(true);
|
||||
expect(ns.matches(ns.unstable)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not permit falsey values for both parts", () => {
|
||||
try {
|
||||
new UnstableValue(null, null);
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Failed to fail");
|
||||
} catch (e) {
|
||||
expect(e.message).toBe("One of stable or unstable values must be supplied");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnstableValue", () => {
|
||||
it("should prefer unstable over stable", () => {
|
||||
const ns = new UnstableValue("stable", "unstable");
|
||||
expect(ns.name).toBe(ns.unstable);
|
||||
expect(ns.altName).toBe(ns.stable);
|
||||
});
|
||||
|
||||
it("should return unstable if there is no stable", () => {
|
||||
const ns = new UnstableValue(null, "unstable");
|
||||
expect(ns.name).toBe(ns.unstable);
|
||||
expect(ns.altName).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not permit falsey unstable values", () => {
|
||||
try {
|
||||
new UnstableValue("stable", null);
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Failed to fail");
|
||||
} catch (e) {
|
||||
expect(e.message).toBe("Unstable value must be supplied");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ describe("Crypto", function() {
|
||||
'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI';
|
||||
device.keys["ed25519:FLIBBLE"] =
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
client._crypto._deviceList.getDeviceByIdentityKey = () => device;
|
||||
client.crypto._deviceList.getDeviceByIdentityKey = () => device;
|
||||
|
||||
encryptionInfo = client.getEventEncryptionInfo(event);
|
||||
expect(encryptionInfo.encrypted).toBeTruthy();
|
||||
@@ -213,7 +213,7 @@ describe("Crypto", function() {
|
||||
|
||||
async function keyshareEventForEvent(event, index) {
|
||||
const eventContent = event.getWireContent();
|
||||
const key = await aliceClient._crypto._olmDevice
|
||||
const key = await aliceClient.crypto._olmDevice
|
||||
.getInboundGroupSessionKey(
|
||||
roomId, eventContent.sender_key, eventContent.session_id,
|
||||
index,
|
||||
@@ -273,19 +273,19 @@ describe("Crypto", function() {
|
||||
await Promise.all(events.map(async (event) => {
|
||||
// alice encrypts each event, and then bob tries to decrypt
|
||||
// them without any keys, so that they'll be in pending
|
||||
await aliceClient._crypto.encryptEvent(event, aliceRoom);
|
||||
await aliceClient.crypto.encryptEvent(event, aliceRoom);
|
||||
event._clearEvent = {};
|
||||
event._senderCurve25519Key = null;
|
||||
event._claimedEd25519Key = null;
|
||||
try {
|
||||
await bobClient._crypto.decryptEvent(event);
|
||||
await bobClient.crypto.decryptEvent(event);
|
||||
} catch (e) {
|
||||
// we expect this to fail because we don't have the
|
||||
// decryption keys yet
|
||||
}
|
||||
}));
|
||||
|
||||
const bobDecryptor = bobClient._crypto._getRoomDecryptor(
|
||||
const bobDecryptor = bobClient.crypto._getRoomDecryptor(
|
||||
roomId, olmlib.MEGOLM_ALGORITHM,
|
||||
);
|
||||
|
||||
@@ -302,7 +302,7 @@ describe("Crypto", function() {
|
||||
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
|
||||
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
|
||||
|
||||
const cryptoStore = bobClient._cryptoStore;
|
||||
const cryptoStore = bobClient.cryptoStore;
|
||||
const eventContent = events[0].getWireContent();
|
||||
const senderKey = eventContent.sender_key;
|
||||
const sessionId = eventContent.session_id;
|
||||
@@ -344,7 +344,7 @@ describe("Crypto", function() {
|
||||
},
|
||||
});
|
||||
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
|
||||
const cryptoStore = aliceClient._cryptoStore;
|
||||
const cryptoStore = aliceClient.cryptoStore;
|
||||
const roomKeyRequestBody = {
|
||||
algorithm: olmlib.MEGOLM_ALGORITHM,
|
||||
room_id: "!someroom",
|
||||
@@ -377,7 +377,7 @@ describe("Crypto", function() {
|
||||
// key requests get queued until the sync has finished, but we don't
|
||||
// let the client set up enough for that to happen, so gut-wrench a bit
|
||||
// to force it to send now.
|
||||
aliceClient._crypto._outgoingRoomKeyRequestManager.sendQueuedRequests();
|
||||
aliceClient.crypto._outgoingRoomKeyRequestManager.sendQueuedRequests();
|
||||
jest.runAllTimers();
|
||||
await Promise.resolve();
|
||||
expect(aliceClient.sendToDevice).toBeCalledTimes(1);
|
||||
|
||||
@@ -257,6 +257,9 @@ describe("MegolmDecryption", function() {
|
||||
});
|
||||
|
||||
it("re-uses sessions for sequential messages", async function() {
|
||||
mockCrypto._backupManager = {
|
||||
backupGroupSession: () => {},
|
||||
};
|
||||
const mockStorage = new MockStorageApi();
|
||||
const cryptoStore = new MemoryCryptoStore(mockStorage);
|
||||
|
||||
@@ -362,9 +365,9 @@ describe("MegolmDecryption", function() {
|
||||
bobClient1.initCrypto(),
|
||||
bobClient2.initCrypto(),
|
||||
]);
|
||||
const aliceDevice = aliceClient._crypto._olmDevice;
|
||||
const bobDevice1 = bobClient1._crypto._olmDevice;
|
||||
const bobDevice2 = bobClient2._crypto._olmDevice;
|
||||
const aliceDevice = aliceClient.crypto._olmDevice;
|
||||
const bobDevice1 = bobClient1.crypto._olmDevice;
|
||||
const bobDevice2 = bobClient2.crypto._olmDevice;
|
||||
|
||||
const encryptionCfg = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
@@ -401,10 +404,10 @@ describe("MegolmDecryption", function() {
|
||||
},
|
||||
};
|
||||
|
||||
aliceClient._crypto._deviceList.storeDevicesForUser(
|
||||
aliceClient.crypto._deviceList.storeDevicesForUser(
|
||||
"@bob:example.com", BOB_DEVICES,
|
||||
);
|
||||
aliceClient._crypto._deviceList.downloadKeys = async function(userIds) {
|
||||
aliceClient.crypto._deviceList.downloadKeys = async function(userIds) {
|
||||
return this._getDevicesFromStore(userIds);
|
||||
};
|
||||
|
||||
@@ -445,7 +448,7 @@ describe("MegolmDecryption", function() {
|
||||
body: "secret",
|
||||
},
|
||||
});
|
||||
await aliceClient._crypto.encryptEvent(event, room);
|
||||
await aliceClient.crypto.encryptEvent(event, room);
|
||||
|
||||
expect(run).toBe(true);
|
||||
|
||||
@@ -465,8 +468,8 @@ describe("MegolmDecryption", function() {
|
||||
aliceClient.initCrypto(),
|
||||
bobClient.initCrypto(),
|
||||
]);
|
||||
const aliceDevice = aliceClient._crypto._olmDevice;
|
||||
const bobDevice = bobClient._crypto._olmDevice;
|
||||
const aliceDevice = aliceClient.crypto._olmDevice;
|
||||
const bobDevice = bobClient.crypto._olmDevice;
|
||||
|
||||
const encryptionCfg = {
|
||||
"algorithm": "m.megolm.v1.aes-sha2",
|
||||
@@ -505,10 +508,10 @@ describe("MegolmDecryption", function() {
|
||||
},
|
||||
};
|
||||
|
||||
aliceClient._crypto._deviceList.storeDevicesForUser(
|
||||
aliceClient.crypto._deviceList.storeDevicesForUser(
|
||||
"@bob:example.com", BOB_DEVICES,
|
||||
);
|
||||
aliceClient._crypto._deviceList.downloadKeys = async function(userIds) {
|
||||
aliceClient.crypto._deviceList.downloadKeys = async function(userIds) {
|
||||
return this._getDevicesFromStore(userIds);
|
||||
};
|
||||
|
||||
@@ -543,7 +546,7 @@ describe("MegolmDecryption", function() {
|
||||
event_id: "$event",
|
||||
content: {},
|
||||
});
|
||||
await aliceClient._crypto.encryptEvent(event, aliceRoom);
|
||||
await aliceClient.crypto.encryptEvent(event, aliceRoom);
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
@@ -558,11 +561,11 @@ describe("MegolmDecryption", function() {
|
||||
aliceClient.initCrypto(),
|
||||
bobClient.initCrypto(),
|
||||
]);
|
||||
const bobDevice = bobClient._crypto._olmDevice;
|
||||
const bobDevice = bobClient.crypto._olmDevice;
|
||||
|
||||
const roomId = "!someroom";
|
||||
|
||||
aliceClient._crypto._onToDeviceEvent(new MatrixEvent({
|
||||
aliceClient.crypto._onToDeviceEvent(new MatrixEvent({
|
||||
type: "org.matrix.room_key.withheld",
|
||||
sender: "@bob:example.com",
|
||||
content: {
|
||||
@@ -575,7 +578,7 @@ describe("MegolmDecryption", function() {
|
||||
},
|
||||
}));
|
||||
|
||||
await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({
|
||||
await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({
|
||||
type: "m.room.encrypted",
|
||||
sender: "@bob:example.com",
|
||||
event_id: "$event",
|
||||
@@ -601,14 +604,14 @@ describe("MegolmDecryption", function() {
|
||||
aliceClient.initCrypto(),
|
||||
bobClient.initCrypto(),
|
||||
]);
|
||||
aliceClient._crypto.downloadKeys = async () => {};
|
||||
const bobDevice = bobClient._crypto._olmDevice;
|
||||
aliceClient.crypto.downloadKeys = async () => {};
|
||||
const bobDevice = bobClient.crypto._olmDevice;
|
||||
|
||||
const roomId = "!someroom";
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
aliceClient._crypto._onToDeviceEvent(new MatrixEvent({
|
||||
aliceClient.crypto._onToDeviceEvent(new MatrixEvent({
|
||||
type: "org.matrix.room_key.withheld",
|
||||
sender: "@bob:example.com",
|
||||
content: {
|
||||
@@ -625,7 +628,7 @@ describe("MegolmDecryption", function() {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({
|
||||
await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({
|
||||
type: "m.room.encrypted",
|
||||
sender: "@bob:example.com",
|
||||
event_id: "$event",
|
||||
@@ -652,15 +655,15 @@ describe("MegolmDecryption", function() {
|
||||
aliceClient.initCrypto(),
|
||||
bobClient.initCrypto(),
|
||||
]);
|
||||
const bobDevice = bobClient._crypto._olmDevice;
|
||||
aliceClient._crypto.downloadKeys = async () => {};
|
||||
const bobDevice = bobClient.crypto._olmDevice;
|
||||
aliceClient.crypto.downloadKeys = async () => {};
|
||||
|
||||
const roomId = "!someroom";
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// pretend we got an event that we can't decrypt
|
||||
aliceClient._crypto._onToDeviceEvent(new MatrixEvent({
|
||||
aliceClient.crypto._onToDeviceEvent(new MatrixEvent({
|
||||
type: "m.room.encrypted",
|
||||
sender: "@bob:example.com",
|
||||
content: {
|
||||
@@ -675,7 +678,7 @@ describe("MegolmDecryption", function() {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({
|
||||
await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({
|
||||
type: "m.room.encrypted",
|
||||
sender: "@bob:example.com",
|
||||
event_id: "$event",
|
||||
|
||||
@@ -28,6 +28,7 @@ import * as testUtils from "../../test-utils";
|
||||
import { OlmDevice } from "../../../src/crypto/OlmDevice";
|
||||
import { Crypto } from "../../../src/crypto";
|
||||
import { resetCrossSigningKeys } from "./crypto-utils";
|
||||
import { BackupManager } from "../../../src/crypto/backup";
|
||||
|
||||
const Olm = global.Olm;
|
||||
|
||||
@@ -73,7 +74,7 @@ const KEY_BACKUP_DATA = {
|
||||
};
|
||||
|
||||
const BACKUP_INFO = {
|
||||
algorithm: "m.megolm_backup.v1",
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: 1,
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
@@ -138,6 +139,7 @@ describe("MegolmBackup", function() {
|
||||
let megolmDecryption;
|
||||
beforeEach(async function() {
|
||||
mockCrypto = testUtils.mock(Crypto, 'Crypto');
|
||||
mockCrypto._backupManager = testUtils.mock(BackupManager, "BackupManager");
|
||||
mockCrypto.backupKey = new Olm.PkEncryption();
|
||||
mockCrypto.backupKey.set_recipient_key(
|
||||
"hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
@@ -215,12 +217,14 @@ describe("MegolmBackup", function() {
|
||||
};
|
||||
mockCrypto.cancelRoomKeyRequest = function() {};
|
||||
|
||||
mockCrypto.backupGroupSession = jest.fn();
|
||||
mockCrypto._backupManager = {
|
||||
backupGroupSession: jest.fn(),
|
||||
};
|
||||
|
||||
return event.attemptDecryption(mockCrypto).then(() => {
|
||||
return megolmDecryption.onRoomKeyEvent(event);
|
||||
}).then(() => {
|
||||
expect(mockCrypto.backupGroupSession).toHaveBeenCalled();
|
||||
expect(mockCrypto._backupManager.backupGroupSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,7 +268,7 @@ describe("MegolmBackup", function() {
|
||||
})
|
||||
.then(() => {
|
||||
client.enableKeyBackup({
|
||||
algorithm: "m.megolm_backup.v1",
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: 1,
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
@@ -272,7 +276,7 @@ describe("MegolmBackup", function() {
|
||||
});
|
||||
let numCalls = 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
client._http.authedRequest = function(
|
||||
client.http.authedRequest = function(
|
||||
callback, method, path, queryParams, data, opts,
|
||||
) {
|
||||
++numCalls;
|
||||
@@ -292,12 +296,9 @@ describe("MegolmBackup", function() {
|
||||
resolve();
|
||||
return Promise.resolve({});
|
||||
};
|
||||
client._crypto.backupGroupSession(
|
||||
"roomId",
|
||||
client.crypto._backupManager.backupGroupSession(
|
||||
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
|
||||
[],
|
||||
groupSession.session_id(),
|
||||
groupSession.session_key(),
|
||||
);
|
||||
}).then(() => {
|
||||
expect(numCalls).toBe(1);
|
||||
@@ -335,39 +336,48 @@ describe("MegolmBackup", function() {
|
||||
});
|
||||
await resetCrossSigningKeys(client);
|
||||
let numCalls = 0;
|
||||
await new Promise((resolve, reject) => {
|
||||
client._http.authedRequest = function(
|
||||
callback, method, path, queryParams, data, opts,
|
||||
) {
|
||||
++numCalls;
|
||||
expect(numCalls).toBeLessThanOrEqual(1);
|
||||
if (numCalls >= 2) {
|
||||
// exit out of retry loop if there's something wrong
|
||||
reject(new Error("authedRequest called too many timmes"));
|
||||
return Promise.resolve({});
|
||||
}
|
||||
expect(method).toBe("POST");
|
||||
expect(path).toBe("/room_keys/version");
|
||||
try {
|
||||
// make sure auth_data is signed by the master key
|
||||
olmlib.pkVerify(
|
||||
data.auth_data, client.getCrossSigningId(), "@alice:bar",
|
||||
);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
return Promise.resolve({});
|
||||
}
|
||||
resolve();
|
||||
return Promise.resolve({});
|
||||
};
|
||||
await Promise.all([
|
||||
new Promise((resolve, reject) => {
|
||||
let backupInfo;
|
||||
client.http.authedRequest = function(
|
||||
callback, method, path, queryParams, data, opts,
|
||||
) {
|
||||
++numCalls;
|
||||
expect(numCalls).toBeLessThanOrEqual(2);
|
||||
if (numCalls === 1) {
|
||||
expect(method).toBe("POST");
|
||||
expect(path).toBe("/room_keys/version");
|
||||
try {
|
||||
// make sure auth_data is signed by the master key
|
||||
olmlib.pkVerify(
|
||||
data.auth_data, client.getCrossSigningId(), "@alice:bar",
|
||||
);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
return Promise.resolve({});
|
||||
}
|
||||
backupInfo = data;
|
||||
return Promise.resolve({});
|
||||
} else if (numCalls === 2) {
|
||||
expect(method).toBe("GET");
|
||||
expect(path).toBe("/room_keys/version");
|
||||
resolve();
|
||||
return Promise.resolve(backupInfo);
|
||||
} else {
|
||||
// exit out of retry loop if there's something wrong
|
||||
reject(new Error("authedRequest called too many times"));
|
||||
return Promise.resolve({});
|
||||
}
|
||||
};
|
||||
}),
|
||||
client.createKeyBackupVersion({
|
||||
algorithm: "m.megolm_backup.v1",
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(numCalls).toBe(1);
|
||||
}),
|
||||
]);
|
||||
expect(numCalls).toBe(2);
|
||||
});
|
||||
|
||||
it('retries when a backup fails', function() {
|
||||
@@ -434,7 +444,7 @@ describe("MegolmBackup", function() {
|
||||
})
|
||||
.then(() => {
|
||||
client.enableKeyBackup({
|
||||
algorithm: "foobar",
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
version: 1,
|
||||
auth_data: {
|
||||
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
|
||||
@@ -442,7 +452,7 @@ describe("MegolmBackup", function() {
|
||||
});
|
||||
let numCalls = 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
client._http.authedRequest = function(
|
||||
client.http.authedRequest = function(
|
||||
callback, method, path, queryParams, data, opts,
|
||||
) {
|
||||
++numCalls;
|
||||
@@ -468,12 +478,9 @@ describe("MegolmBackup", function() {
|
||||
);
|
||||
}
|
||||
};
|
||||
client._crypto.backupGroupSession(
|
||||
"roomId",
|
||||
client.crypto._backupManager.backupGroupSession(
|
||||
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
|
||||
[],
|
||||
groupSession.session_id(),
|
||||
groupSession.session_key(),
|
||||
);
|
||||
}).then(() => {
|
||||
expect(numCalls).toBe(2);
|
||||
@@ -506,7 +513,7 @@ describe("MegolmBackup", function() {
|
||||
});
|
||||
|
||||
it('can restore from backup', function() {
|
||||
client._http.authedRequest = function() {
|
||||
client.http.authedRequest = function() {
|
||||
return Promise.resolve(KEY_BACKUP_DATA);
|
||||
};
|
||||
return client.restoreKeyBackupWithRecoveryKey(
|
||||
@@ -523,7 +530,7 @@ describe("MegolmBackup", function() {
|
||||
});
|
||||
|
||||
it('can restore backup by room', function() {
|
||||
client._http.authedRequest = function() {
|
||||
client.http.authedRequest = function() {
|
||||
return Promise.resolve({
|
||||
rooms: {
|
||||
[ROOM_ID]: {
|
||||
@@ -546,15 +553,15 @@ describe("MegolmBackup", function() {
|
||||
|
||||
it('has working cache functions', async function() {
|
||||
const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
await client._crypto.storeSessionBackupPrivateKey(key);
|
||||
const result = await client._crypto.getSessionBackupPrivateKey();
|
||||
await client.crypto.storeSessionBackupPrivateKey(key);
|
||||
const result = await client.crypto.getSessionBackupPrivateKey();
|
||||
expect(new Uint8Array(result)).toEqual(key);
|
||||
});
|
||||
|
||||
it('caches session backup keys as it encounters them', async function() {
|
||||
const cachedNull = await client._crypto.getSessionBackupPrivateKey();
|
||||
const cachedNull = await client.crypto.getSessionBackupPrivateKey();
|
||||
expect(cachedNull).toBeNull();
|
||||
client._http.authedRequest = function() {
|
||||
client.http.authedRequest = function() {
|
||||
return Promise.resolve(KEY_BACKUP_DATA);
|
||||
};
|
||||
await new Promise((resolve) => {
|
||||
@@ -566,8 +573,24 @@ describe("MegolmBackup", function() {
|
||||
{ cacheCompleteCallback: resolve },
|
||||
);
|
||||
});
|
||||
const cachedKey = await client._crypto.getSessionBackupPrivateKey();
|
||||
const cachedKey = await client.crypto.getSessionBackupPrivateKey();
|
||||
expect(cachedKey).not.toBeNull();
|
||||
});
|
||||
|
||||
it("fails if an known algorithm is used", async function() {
|
||||
const BAD_BACKUP_INFO = Object.assign({}, BACKUP_INFO, {
|
||||
algorithm: "this.algorithm.does.not.exist",
|
||||
});
|
||||
client.http.authedRequest = function() {
|
||||
return Promise.resolve(KEY_BACKUP_DATA);
|
||||
};
|
||||
|
||||
await expect(client.restoreKeyBackupWithRecoveryKey(
|
||||
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
|
||||
ROOM_ID,
|
||||
SESSION_ID,
|
||||
BAD_BACKUP_INFO,
|
||||
)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,8 +64,8 @@ describe("Cross Signing", function() {
|
||||
);
|
||||
alice.uploadDeviceSigningKeys = jest.fn(async (auth, keys) => {
|
||||
await olmlib.verifySignature(
|
||||
alice._crypto._olmDevice, keys.master_key, "@alice:example.com",
|
||||
"Osborne2", alice._crypto._olmDevice.deviceEd25519Key,
|
||||
alice.crypto._olmDevice, keys.master_key, "@alice:example.com",
|
||||
"Osborne2", alice.crypto._olmDevice.deviceEd25519Key,
|
||||
);
|
||||
});
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
@@ -138,7 +138,7 @@ describe("Cross Signing", function() {
|
||||
// set Alice's cross-signing key
|
||||
await resetCrossSigningKeys(alice);
|
||||
// Alice downloads Bob's device key
|
||||
alice._crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
|
||||
alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
|
||||
keys: {
|
||||
master: {
|
||||
user_id: "@bob:example.com",
|
||||
@@ -201,24 +201,28 @@ describe("Cross Signing", function() {
|
||||
|
||||
const uploadSigsPromise = new Promise((resolve, reject) => {
|
||||
alice.uploadKeySignatures = jest.fn(async (content) => {
|
||||
await olmlib.verifySignature(
|
||||
alice._crypto._olmDevice,
|
||||
content["@alice:example.com"][
|
||||
"nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"
|
||||
],
|
||||
"@alice:example.com",
|
||||
"Osborne2", alice._crypto._olmDevice.deviceEd25519Key,
|
||||
);
|
||||
olmlib.pkVerify(
|
||||
content["@alice:example.com"]["Osborne2"],
|
||||
"EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ",
|
||||
"@alice:example.com",
|
||||
);
|
||||
resolve();
|
||||
try {
|
||||
await olmlib.verifySignature(
|
||||
alice.crypto._olmDevice,
|
||||
content["@alice:example.com"][
|
||||
"nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"
|
||||
],
|
||||
"@alice:example.com",
|
||||
"Osborne2", alice.crypto._olmDevice.deviceEd25519Key,
|
||||
);
|
||||
olmlib.pkVerify(
|
||||
content["@alice:example.com"]["Osborne2"],
|
||||
"EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ",
|
||||
"@alice:example.com",
|
||||
);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const deviceInfo = alice._crypto._deviceList._devices["@alice:example.com"]
|
||||
const deviceInfo = alice.crypto._deviceList._devices["@alice:example.com"]
|
||||
.Osborne2;
|
||||
const aliceDevice = {
|
||||
user_id: "@alice:example.com",
|
||||
@@ -226,7 +230,7 @@ describe("Cross Signing", function() {
|
||||
};
|
||||
aliceDevice.keys = deviceInfo.keys;
|
||||
aliceDevice.algorithms = deviceInfo.algorithms;
|
||||
await alice._crypto._signObject(aliceDevice);
|
||||
await alice.crypto._signObject(aliceDevice);
|
||||
olmlib.pkSign(aliceDevice, selfSigningKey, "@alice:example.com");
|
||||
|
||||
// feed sync result that includes master key, ssk, device key
|
||||
@@ -354,7 +358,7 @@ describe("Cross Signing", function() {
|
||||
["ed25519:" + bobMasterPubkey]: sskSig,
|
||||
},
|
||||
};
|
||||
alice._crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
|
||||
alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
|
||||
keys: {
|
||||
master: {
|
||||
user_id: "@bob:example.com",
|
||||
@@ -383,7 +387,7 @@ describe("Cross Signing", function() {
|
||||
["ed25519:" + bobPubkey]: sig,
|
||||
},
|
||||
};
|
||||
alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
Dynabook: bobDevice,
|
||||
});
|
||||
// Bob's device key should be TOFU
|
||||
@@ -417,8 +421,8 @@ describe("Cross Signing", function() {
|
||||
null,
|
||||
aliceKeys,
|
||||
);
|
||||
alice._crypto._deviceList.startTrackingDeviceList("@bob:example.com");
|
||||
alice._crypto._deviceList.stopTrackingAllDeviceLists = () => {};
|
||||
alice.crypto._deviceList.startTrackingDeviceList("@bob:example.com");
|
||||
alice.crypto._deviceList.stopTrackingAllDeviceLists = () => {};
|
||||
alice.uploadDeviceSigningKeys = async () => {};
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
|
||||
@@ -433,14 +437,14 @@ describe("Cross Signing", function() {
|
||||
]);
|
||||
|
||||
const keyChangePromise = new Promise((resolve, reject) => {
|
||||
alice._crypto._deviceList.once("userCrossSigningUpdated", (userId) => {
|
||||
alice.crypto._deviceList.once("userCrossSigningUpdated", (userId) => {
|
||||
if (userId === "@bob:example.com") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const deviceInfo = alice._crypto._deviceList._devices["@alice:example.com"]
|
||||
const deviceInfo = alice.crypto._deviceList._devices["@alice:example.com"]
|
||||
.Osborne2;
|
||||
const aliceDevice = {
|
||||
user_id: "@alice:example.com",
|
||||
@@ -448,7 +452,7 @@ describe("Cross Signing", function() {
|
||||
};
|
||||
aliceDevice.keys = deviceInfo.keys;
|
||||
aliceDevice.algorithms = deviceInfo.algorithms;
|
||||
await alice._crypto._signObject(aliceDevice);
|
||||
await alice.crypto._signObject(aliceDevice);
|
||||
|
||||
const bobOlmAccount = new global.Olm.Account();
|
||||
bobOlmAccount.create();
|
||||
@@ -602,7 +606,7 @@ describe("Cross Signing", function() {
|
||||
["ed25519:" + bobMasterPubkey]: sskSig,
|
||||
},
|
||||
};
|
||||
alice._crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
|
||||
alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
|
||||
keys: {
|
||||
master: {
|
||||
user_id: "@bob:example.com",
|
||||
@@ -625,7 +629,7 @@ describe("Cross Signing", function() {
|
||||
"ed25519:Dynabook": "someOtherPubkey",
|
||||
},
|
||||
};
|
||||
alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
Dynabook: bobDevice,
|
||||
});
|
||||
// Bob's device key should be untrusted
|
||||
@@ -669,7 +673,7 @@ describe("Cross Signing", function() {
|
||||
["ed25519:" + bobMasterPubkey]: sskSig,
|
||||
},
|
||||
};
|
||||
alice._crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
|
||||
alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
|
||||
keys: {
|
||||
master: {
|
||||
user_id: "@bob:example.com",
|
||||
@@ -697,7 +701,7 @@ describe("Cross Signing", function() {
|
||||
bobDevice.signatures = {};
|
||||
bobDevice.signatures["@bob:example.com"] = {};
|
||||
bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey] = sig;
|
||||
alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
Dynabook: bobDevice,
|
||||
});
|
||||
// Alice verifies Bob's SSK
|
||||
@@ -729,7 +733,7 @@ describe("Cross Signing", function() {
|
||||
["ed25519:" + bobMasterPubkey2]: sskSig2,
|
||||
},
|
||||
};
|
||||
alice._crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
|
||||
alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", {
|
||||
keys: {
|
||||
master: {
|
||||
user_id: "@bob:example.com",
|
||||
@@ -766,7 +770,7 @@ describe("Cross Signing", function() {
|
||||
// Alice gets new signature for device
|
||||
const sig2 = bobSigning2.sign(bobDeviceString);
|
||||
bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey2] = sig2;
|
||||
alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
Dynabook: bobDevice,
|
||||
});
|
||||
|
||||
@@ -801,20 +805,20 @@ describe("Cross Signing", function() {
|
||||
bob.uploadKeySignatures = async () => {};
|
||||
// set Bob's cross-signing key
|
||||
await resetCrossSigningKeys(bob);
|
||||
alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
Dynabook: {
|
||||
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
|
||||
keys: {
|
||||
"curve25519:Dynabook": bob._crypto._olmDevice.deviceCurve25519Key,
|
||||
"ed25519:Dynabook": bob._crypto._olmDevice.deviceEd25519Key,
|
||||
"curve25519:Dynabook": bob.crypto._olmDevice.deviceCurve25519Key,
|
||||
"ed25519:Dynabook": bob.crypto._olmDevice.deviceEd25519Key,
|
||||
},
|
||||
verified: 1,
|
||||
known: true,
|
||||
},
|
||||
});
|
||||
alice._crypto._deviceList.storeCrossSigningForUser(
|
||||
alice.crypto._deviceList.storeCrossSigningForUser(
|
||||
"@bob:example.com",
|
||||
bob._crypto._crossSigningInfo.toStorage(),
|
||||
bob.crypto._crossSigningInfo.toStorage(),
|
||||
);
|
||||
|
||||
alice.uploadDeviceSigningKeys = async () => {};
|
||||
@@ -834,7 +838,7 @@ describe("Cross Signing", function() {
|
||||
expect(bobTrust.isTofu()).toBeTruthy();
|
||||
|
||||
// "forget" that Bob is trusted
|
||||
delete alice._crypto._deviceList._crossSigningInfo["@bob:example.com"]
|
||||
delete alice.crypto._deviceList._crossSigningInfo["@bob:example.com"]
|
||||
.keys.master.signatures["@alice:example.com"];
|
||||
|
||||
const bobTrust2 = alice.checkUserTrust("@bob:example.com");
|
||||
@@ -844,9 +848,9 @@ describe("Cross Signing", function() {
|
||||
upgradePromise = new Promise((resolve) => {
|
||||
upgradeResolveFunc = resolve;
|
||||
});
|
||||
alice._crypto._deviceList.emit("userCrossSigningUpdated", "@bob:example.com");
|
||||
alice.crypto._deviceList.emit("userCrossSigningUpdated", "@bob:example.com");
|
||||
await new Promise((resolve) => {
|
||||
alice._crypto.on("userTrustStatusChanged", resolve);
|
||||
alice.crypto.on("userTrustStatusChanged", resolve);
|
||||
});
|
||||
await upgradePromise;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function resetCrossSigningKeys(client, {
|
||||
level,
|
||||
authUploadDeviceSigningKeys = async func => await func(),
|
||||
} = {}) {
|
||||
const crypto = client._crypto;
|
||||
const crypto = client.crypto;
|
||||
|
||||
const oldKeys = Object.assign({}, crypto._crossSigningInfo.keys);
|
||||
try {
|
||||
|
||||
@@ -47,7 +47,7 @@ async function makeTestClient(userInfo, options) {
|
||||
await client.initCrypto();
|
||||
|
||||
// No need to download keys for these tests
|
||||
client._crypto.downloadKeys = async function() {};
|
||||
client.crypto.downloadKeys = async function() {};
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -99,11 +99,11 @@ describe("Secrets", function() {
|
||||
},
|
||||
},
|
||||
);
|
||||
alice._crypto._crossSigningInfo.setKeys({
|
||||
alice.crypto._crossSigningInfo.setKeys({
|
||||
master: signingkeyInfo,
|
||||
});
|
||||
|
||||
const secretStorage = alice._crypto._secretStorage;
|
||||
const secretStorage = alice.crypto._secretStorage;
|
||||
|
||||
alice.setAccountData = async function(eventType, contents, callback) {
|
||||
alice.store.storeAccountDataEvents([
|
||||
@@ -120,7 +120,7 @@ describe("Secrets", function() {
|
||||
const keyAccountData = {
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
};
|
||||
await alice._crypto._crossSigningInfo.signObject(keyAccountData, 'master');
|
||||
await alice.crypto._crossSigningInfo.signObject(keyAccountData, 'master');
|
||||
|
||||
alice.store.storeAccountDataEvents([
|
||||
new MatrixEvent({
|
||||
@@ -234,11 +234,11 @@ describe("Secrets", function() {
|
||||
},
|
||||
);
|
||||
|
||||
const vaxDevice = vax.client._crypto._olmDevice;
|
||||
const osborne2Device = osborne2.client._crypto._olmDevice;
|
||||
const secretStorage = osborne2.client._crypto._secretStorage;
|
||||
const vaxDevice = vax.client.crypto._olmDevice;
|
||||
const osborne2Device = osborne2.client.crypto._olmDevice;
|
||||
const secretStorage = osborne2.client.crypto._secretStorage;
|
||||
|
||||
osborne2.client._crypto._deviceList.storeDevicesForUser("@alice:example.com", {
|
||||
osborne2.client.crypto._deviceList.storeDevicesForUser("@alice:example.com", {
|
||||
"VAX": {
|
||||
user_id: "@alice:example.com",
|
||||
device_id: "VAX",
|
||||
@@ -249,7 +249,7 @@ describe("Secrets", function() {
|
||||
},
|
||||
},
|
||||
});
|
||||
vax.client._crypto._deviceList.storeDevicesForUser("@alice:example.com", {
|
||||
vax.client.crypto._deviceList.storeDevicesForUser("@alice:example.com", {
|
||||
"Osborne2": {
|
||||
user_id: "@alice:example.com",
|
||||
device_id: "Osborne2",
|
||||
@@ -265,7 +265,7 @@ describe("Secrets", function() {
|
||||
const otks = (await osborne2Device.getOneTimeKeys()).curve25519;
|
||||
await osborne2Device.markKeysAsPublished();
|
||||
|
||||
await vax.client._crypto._olmDevice.createOutboundSession(
|
||||
await vax.client.crypto._olmDevice.createOutboundSession(
|
||||
osborne2Device.deviceCurve25519Key,
|
||||
Object.values(otks)[0],
|
||||
);
|
||||
@@ -334,8 +334,8 @@ describe("Secrets", function() {
|
||||
createSecretStorageKey,
|
||||
});
|
||||
|
||||
const crossSigning = bob._crypto._crossSigningInfo;
|
||||
const secretStorage = bob._crypto._secretStorage;
|
||||
const crossSigning = bob.crypto._crossSigningInfo;
|
||||
const secretStorage = bob.crypto._secretStorage;
|
||||
|
||||
expect(crossSigning.getId()).toBeTruthy();
|
||||
expect(await crossSigning.isStoredInSecretStorage(secretStorage))
|
||||
@@ -376,10 +376,10 @@ describe("Secrets", function() {
|
||||
]);
|
||||
this.emit("accountData", event);
|
||||
};
|
||||
bob._crypto.checkKeyBackup = async () => {};
|
||||
bob.crypto._backupManager.checkKeyBackup = async () => {};
|
||||
|
||||
const crossSigning = bob._crypto._crossSigningInfo;
|
||||
const secretStorage = bob._crypto._secretStorage;
|
||||
const crossSigning = bob.crypto._crossSigningInfo;
|
||||
const secretStorage = bob.crypto._secretStorage;
|
||||
|
||||
// Set up cross-signing keys from scratch with specific storage key
|
||||
await bob.bootstrapCrossSigning({
|
||||
@@ -394,7 +394,7 @@ describe("Secrets", function() {
|
||||
});
|
||||
|
||||
// Clear local cross-signing keys and read from secret storage
|
||||
bob._crypto._deviceList.storeCrossSigningForUser(
|
||||
bob.crypto._deviceList.storeCrossSigningForUser(
|
||||
"@bob:example.com",
|
||||
crossSigning.toStorage(),
|
||||
);
|
||||
@@ -479,7 +479,7 @@ describe("Secrets", function() {
|
||||
},
|
||||
}),
|
||||
]);
|
||||
alice._crypto._deviceList.storeCrossSigningForUser("@alice:example.com", {
|
||||
alice.crypto._deviceList.storeCrossSigningForUser("@alice:example.com", {
|
||||
keys: {
|
||||
master: {
|
||||
user_id: "@alice:example.com",
|
||||
@@ -619,7 +619,7 @@ describe("Secrets", function() {
|
||||
},
|
||||
}),
|
||||
]);
|
||||
alice._crypto._deviceList.storeCrossSigningForUser("@alice:example.com", {
|
||||
alice.crypto._deviceList.storeCrossSigningForUser("@alice:example.com", {
|
||||
keys: {
|
||||
master: {
|
||||
user_id: "@alice:example.com",
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("verification request integration tests with crypto layer", function()
|
||||
verificationMethods: [verificationMethods.SAS],
|
||||
},
|
||||
);
|
||||
alice.client._crypto._deviceList.getRawStoredDevicesForUser = function() {
|
||||
alice.client.crypto._deviceList.getRawStoredDevicesForUser = function() {
|
||||
return {
|
||||
Dynabook: {
|
||||
keys: {
|
||||
|
||||
@@ -87,8 +87,8 @@ describe("SAS verification", function() {
|
||||
},
|
||||
);
|
||||
|
||||
const aliceDevice = alice.client._crypto._olmDevice;
|
||||
const bobDevice = bob.client._crypto._olmDevice;
|
||||
const aliceDevice = alice.client.crypto._olmDevice;
|
||||
const bobDevice = bob.client.crypto._olmDevice;
|
||||
|
||||
ALICE_DEVICES = {
|
||||
Osborne2: {
|
||||
@@ -114,14 +114,14 @@ describe("SAS verification", function() {
|
||||
},
|
||||
};
|
||||
|
||||
alice.client._crypto._deviceList.storeDevicesForUser(
|
||||
alice.client.crypto._deviceList.storeDevicesForUser(
|
||||
"@bob:example.com", BOB_DEVICES,
|
||||
);
|
||||
alice.client.downloadKeys = () => {
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
bob.client._crypto._deviceList.storeDevicesForUser(
|
||||
bob.client.crypto._deviceList.storeDevicesForUser(
|
||||
"@alice:example.com", ALICE_DEVICES,
|
||||
);
|
||||
bob.client.downloadKeys = () => {
|
||||
@@ -296,9 +296,9 @@ describe("SAS verification", function() {
|
||||
|
||||
await resetCrossSigningKeys(bob.client);
|
||||
|
||||
bob.client._crypto._deviceList.storeCrossSigningForUser(
|
||||
bob.client.crypto._deviceList.storeCrossSigningForUser(
|
||||
"@alice:example.com", {
|
||||
keys: alice.client._crypto._crossSigningInfo.keys,
|
||||
keys: alice.client.crypto._crossSigningInfo.keys,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("self-verifications", () => {
|
||||
const restoreKeyBackupWithCache = jest.fn(() => Promise.resolve());
|
||||
|
||||
const client = {
|
||||
_crypto: {
|
||||
crypto: {
|
||||
_crossSigningInfo,
|
||||
_secretStorage,
|
||||
storeSessionBackupPrivateKey,
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function makeTestClients(userInfos, options) {
|
||||
});
|
||||
const client = clientMap[userId][deviceId];
|
||||
const decryptionPromise = event.isEncrypted() ?
|
||||
event.attemptDecryption(client._crypto) :
|
||||
event.attemptDecryption(client.crypto) :
|
||||
Promise.resolve();
|
||||
|
||||
decryptionPromise.then(
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { logger } from "../../src/logger";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { Filter } from "../../src/filter";
|
||||
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace";
|
||||
import {
|
||||
EventType,
|
||||
RoomCreateTypeField,
|
||||
RoomType,
|
||||
UNSTABLE_MSC3088_ENABLED,
|
||||
UNSTABLE_MSC3088_PURPOSE,
|
||||
UNSTABLE_MSC3089_TREE_SUBTYPE,
|
||||
} from "../../src/@types/event";
|
||||
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
|
||||
import { MatrixEvent } from "../../src/models/event";
|
||||
import { Preset } from "../../src/@types/partials";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -144,12 +156,12 @@ describe("MatrixClient", function() {
|
||||
scheduler: scheduler,
|
||||
userId: userId,
|
||||
});
|
||||
// FIXME: We shouldn't be yanking _http like this.
|
||||
client._http = [
|
||||
// FIXME: We shouldn't be yanking http like this.
|
||||
client.http = [
|
||||
"authedRequest", "getContentUri", "request", "uploadContent",
|
||||
].reduce((r, k) => { r[k] = jest.fn(); return r; }, {});
|
||||
client._http.authedRequest.mockImplementation(httpReq);
|
||||
client._http.request.mockImplementation(httpReq);
|
||||
client.http.authedRequest.mockImplementation(httpReq);
|
||||
client.http.request.mockImplementation(httpReq);
|
||||
|
||||
// set reasonable working defaults
|
||||
acceptKeepalives = true;
|
||||
@@ -166,11 +178,165 @@ describe("MatrixClient", function() {
|
||||
// means they may call /events and then fail an expect() which will fail
|
||||
// a DIFFERENT test (pollution between tests!) - we return unresolved
|
||||
// promises to stop the client from continuing to run.
|
||||
client._http.authedRequest.mockImplementation(function() {
|
||||
client.http.authedRequest.mockImplementation(function() {
|
||||
return new Promise(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
it("should create (unstable) file trees", async () => {
|
||||
const userId = "@test:example.org";
|
||||
const roomId = "!room:example.org";
|
||||
const roomName = "Test Tree";
|
||||
const mockRoom = {};
|
||||
const fn = jest.fn().mockImplementation((opts) => {
|
||||
expect(opts).toMatchObject({
|
||||
name: roomName,
|
||||
preset: Preset.PrivateChat,
|
||||
power_level_content_override: {
|
||||
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
|
||||
users: {
|
||||
[userId]: 100,
|
||||
},
|
||||
},
|
||||
creation_content: {
|
||||
[RoomCreateTypeField]: RoomType.Space,
|
||||
},
|
||||
initial_state: [
|
||||
{
|
||||
// We use `unstable` to ensure that the code is actually using the right identifier
|
||||
type: UNSTABLE_MSC3088_PURPOSE.unstable,
|
||||
state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.unstable,
|
||||
content: {
|
||||
[UNSTABLE_MSC3088_ENABLED.unstable]: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: EventType.RoomEncryption,
|
||||
state_key: "",
|
||||
content: {
|
||||
algorithm: MEGOLM_ALGORITHM,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return { room_id: roomId };
|
||||
});
|
||||
client.getUserId = () => userId;
|
||||
client.createRoom = fn;
|
||||
client.getRoom = (getRoomId) => {
|
||||
expect(getRoomId).toEqual(roomId);
|
||||
return mockRoom;
|
||||
};
|
||||
const tree = await client.unstableCreateFileTree(roomName);
|
||||
expect(tree).toBeDefined();
|
||||
expect(tree.roomId).toEqual(roomId);
|
||||
expect(tree.room).toBe(mockRoom);
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should get (unstable) file trees with valid state", async () => {
|
||||
const roomId = "!room:example.org";
|
||||
const mockRoom = {
|
||||
currentState: {
|
||||
getStateEvents: (eventType, stateKey) => {
|
||||
if (eventType === EventType.RoomCreate) {
|
||||
expect(stateKey).toEqual("");
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
[RoomCreateTypeField]: RoomType.Space,
|
||||
},
|
||||
});
|
||||
} else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) {
|
||||
// We use `unstable` to ensure that the code is actually using the right identifier
|
||||
expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable);
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
[UNSTABLE_MSC3088_ENABLED.unstable]: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new Error("Unexpected event type or state key");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
client.getRoom = (getRoomId) => {
|
||||
expect(getRoomId).toEqual(roomId);
|
||||
return mockRoom;
|
||||
};
|
||||
const tree = client.unstableGetFileTreeSpace(roomId);
|
||||
expect(tree).toBeDefined();
|
||||
expect(tree.roomId).toEqual(roomId);
|
||||
expect(tree.room).toBe(mockRoom);
|
||||
});
|
||||
|
||||
it("should not get (unstable) file trees with invalid create contents", async () => {
|
||||
const roomId = "!room:example.org";
|
||||
const mockRoom = {
|
||||
currentState: {
|
||||
getStateEvents: (eventType, stateKey) => {
|
||||
if (eventType === EventType.RoomCreate) {
|
||||
expect(stateKey).toEqual("");
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
[RoomCreateTypeField]: "org.example.not_space",
|
||||
},
|
||||
});
|
||||
} else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) {
|
||||
// We use `unstable` to ensure that the code is actually using the right identifier
|
||||
expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable);
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
[UNSTABLE_MSC3088_ENABLED.unstable]: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new Error("Unexpected event type or state key");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
client.getRoom = (getRoomId) => {
|
||||
expect(getRoomId).toEqual(roomId);
|
||||
return mockRoom;
|
||||
};
|
||||
const tree = client.unstableGetFileTreeSpace(roomId);
|
||||
expect(tree).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not get (unstable) file trees with invalid purpose/subtype contents", async () => {
|
||||
const roomId = "!room:example.org";
|
||||
const mockRoom = {
|
||||
currentState: {
|
||||
getStateEvents: (eventType, stateKey) => {
|
||||
if (eventType === EventType.RoomCreate) {
|
||||
expect(stateKey).toEqual("");
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
[RoomCreateTypeField]: RoomType.Space,
|
||||
},
|
||||
});
|
||||
} else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) {
|
||||
expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable);
|
||||
return new MatrixEvent({
|
||||
content: {
|
||||
[UNSTABLE_MSC3088_ENABLED.unstable]: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new Error("Unexpected event type or state key");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
client.getRoom = (getRoomId) => {
|
||||
expect(getRoomId).toEqual(roomId);
|
||||
return mockRoom;
|
||||
};
|
||||
const tree = client.unstableGetFileTreeSpace(roomId);
|
||||
expect(tree).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not POST /filter if a matching filter already exists", async function() {
|
||||
httpLookups = [];
|
||||
httpLookups.push(PUSH_RULES_RESPONSE);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "../../../src";
|
||||
import { Room } from "../../../src/models/room";
|
||||
import { MatrixEvent } from "../../../src/models/event";
|
||||
import { UNSTABLE_MSC3089_BRANCH } from "../../../src/@types/event";
|
||||
import { EventTimelineSet } from "../../../src/models/event-timeline-set";
|
||||
import { EventTimeline } from "../../../src/models/event-timeline";
|
||||
import { MSC3089Branch } from "../../../src/models/MSC3089Branch";
|
||||
|
||||
describe("MSC3089Branch", () => {
|
||||
let client: MatrixClient;
|
||||
// @ts-ignore - TS doesn't know that this is a type
|
||||
let indexEvent: MatrixEvent;
|
||||
let branch: MSC3089Branch;
|
||||
|
||||
const branchRoomId = "!room:example.org";
|
||||
const fileEventId = "$file";
|
||||
|
||||
const staticTimelineSets = {} as EventTimelineSet;
|
||||
const staticRoom = {
|
||||
getUnfilteredTimelineSet: () => staticTimelineSets,
|
||||
} as any as Room; // partial
|
||||
|
||||
beforeEach(() => {
|
||||
// TODO: Use utility functions to create test rooms and clients
|
||||
client = <MatrixClient>{
|
||||
getRoom: (roomId: string) => {
|
||||
if (roomId === branchRoomId) {
|
||||
return staticRoom;
|
||||
} else {
|
||||
throw new Error("Unexpected fetch for unknown room");
|
||||
}
|
||||
},
|
||||
};
|
||||
indexEvent = {
|
||||
getRoomId: () => branchRoomId,
|
||||
getStateKey: () => fileEventId,
|
||||
};
|
||||
branch = new MSC3089Branch(client, indexEvent);
|
||||
});
|
||||
|
||||
it('should know the file event ID', () => {
|
||||
expect(branch.id).toEqual(fileEventId);
|
||||
});
|
||||
|
||||
it('should know if the file is active or not', () => {
|
||||
indexEvent.getContent = () => ({});
|
||||
expect(branch.isActive).toBe(false);
|
||||
indexEvent.getContent = () => ({ active: false });
|
||||
expect(branch.isActive).toBe(false);
|
||||
indexEvent.getContent = () => ({ active: true });
|
||||
expect(branch.isActive).toBe(true);
|
||||
indexEvent.getContent = () => ({ active: "true" }); // invalid boolean, inactive
|
||||
expect(branch.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should be able to delete the file', async () => {
|
||||
const stateFn = jest.fn()
|
||||
.mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => {
|
||||
expect(roomId).toEqual(branchRoomId);
|
||||
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value
|
||||
expect(content).toMatchObject({});
|
||||
expect(content['active']).toBeUndefined();
|
||||
expect(stateKey).toEqual(fileEventId);
|
||||
|
||||
return Promise.resolve(); // return value not used
|
||||
});
|
||||
client.sendStateEvent = stateFn;
|
||||
|
||||
const redactFn = jest.fn().mockImplementation((roomId: string, eventId: string) => {
|
||||
expect(roomId).toEqual(branchRoomId);
|
||||
expect(eventId).toEqual(fileEventId);
|
||||
|
||||
return Promise.resolve(); // return value not used
|
||||
});
|
||||
client.redactEvent = redactFn;
|
||||
|
||||
await branch.delete();
|
||||
|
||||
expect(stateFn).toHaveBeenCalledTimes(1);
|
||||
expect(redactFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should know its name', async () => {
|
||||
const name = "My File.txt";
|
||||
indexEvent.getContent = () => ({ active: true, name: name });
|
||||
|
||||
const res = branch.getName();
|
||||
|
||||
expect(res).toEqual(name);
|
||||
});
|
||||
|
||||
it('should be able to change its name', async () => {
|
||||
const name = "My File.txt";
|
||||
indexEvent.getContent = () => ({ active: true, retained: true });
|
||||
const stateFn = jest.fn()
|
||||
.mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => {
|
||||
expect(roomId).toEqual(branchRoomId);
|
||||
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value
|
||||
expect(content).toMatchObject({
|
||||
retained: true, // canary for copying state
|
||||
active: true,
|
||||
name: name,
|
||||
});
|
||||
expect(stateKey).toEqual(fileEventId);
|
||||
|
||||
return Promise.resolve(); // return value not used
|
||||
});
|
||||
client.sendStateEvent = stateFn;
|
||||
|
||||
await branch.setName(name);
|
||||
|
||||
expect(stateFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should be able to return event information', async () => {
|
||||
const mxcLatter = "example.org/file";
|
||||
const fileContent = { isFile: "not quite", url: "mxc://" + mxcLatter };
|
||||
const eventsArr = [
|
||||
{ getId: () => "$not-file", getContent: () => ({}) },
|
||||
{ getId: () => fileEventId, getContent: () => ({ file: fileContent }) },
|
||||
];
|
||||
client.getEventTimeline = () => Promise.resolve({
|
||||
getEvents: () => eventsArr,
|
||||
}) as any as Promise<EventTimeline>; // partial
|
||||
client.mxcUrlToHttp = (mxc: string) => {
|
||||
expect(mxc).toEqual("mxc://" + mxcLatter);
|
||||
return `https://example.org/_matrix/media/v1/download/${mxcLatter}`;
|
||||
};
|
||||
client.decryptEventIfNeeded = () => Promise.resolve();
|
||||
|
||||
const res = await branch.getFileInfo();
|
||||
expect(res).toBeDefined();
|
||||
expect(res).toMatchObject({
|
||||
info: fileContent,
|
||||
// Escape regex from MDN guides: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
|
||||
httpUrl: expect.stringMatching(`.+${mxcLatter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,857 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "../../../src";
|
||||
import { Room } from "../../../src/models/room";
|
||||
import { MatrixEvent } from "../../../src/models/event";
|
||||
import { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../../../src/@types/event";
|
||||
import {
|
||||
DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
|
||||
MSC3089TreeSpace,
|
||||
TreePermissions,
|
||||
} from "../../../src/models/MSC3089TreeSpace";
|
||||
import { DEFAULT_ALPHABET } from "../../../src/utils";
|
||||
import { MockBlob } from "../../MockBlob";
|
||||
|
||||
describe("MSC3089TreeSpace", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room;
|
||||
let tree: MSC3089TreeSpace;
|
||||
const roomId = "!tree:localhost";
|
||||
const targetUser = "@target:example.org";
|
||||
|
||||
let powerLevels;
|
||||
|
||||
beforeEach(() => {
|
||||
// TODO: Use utility functions to create test rooms and clients
|
||||
client = <MatrixClient>{
|
||||
getRoom: (fetchRoomId: string) => {
|
||||
if (fetchRoomId === roomId) {
|
||||
return room;
|
||||
} else {
|
||||
throw new Error("Unexpected fetch for unknown room");
|
||||
}
|
||||
},
|
||||
};
|
||||
room = <Room>{
|
||||
currentState: {
|
||||
getStateEvents: (evType: EventType, stateKey: string) => {
|
||||
if (evType === EventType.RoomPowerLevels && stateKey === "") {
|
||||
return powerLevels;
|
||||
} else {
|
||||
throw new Error("Accessed unexpected state event type or key");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
tree = new MSC3089TreeSpace(client, roomId);
|
||||
makePowerLevels(DEFAULT_TREE_POWER_LEVELS_TEMPLATE);
|
||||
});
|
||||
|
||||
function makePowerLevels(content: any) {
|
||||
powerLevels = new MatrixEvent({
|
||||
type: EventType.RoomPowerLevels,
|
||||
state_key: "",
|
||||
sender: "@creator:localhost",
|
||||
event_id: "$powerlevels",
|
||||
room_id: roomId,
|
||||
content: content,
|
||||
});
|
||||
}
|
||||
|
||||
it('should populate the room reference', () => {
|
||||
expect(tree.room).toBe(room);
|
||||
});
|
||||
|
||||
it('should proxy the ID member to room ID', () => {
|
||||
expect(tree.id).toEqual(tree.roomId);
|
||||
expect(tree.id).toEqual(roomId);
|
||||
});
|
||||
|
||||
it('should support setting the name of the space', async () => {
|
||||
const newName = "NEW NAME";
|
||||
const fn = jest.fn()
|
||||
.mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => {
|
||||
expect(stateRoomId).toEqual(roomId);
|
||||
expect(eventType).toEqual(EventType.RoomName);
|
||||
expect(stateKey).toEqual("");
|
||||
expect(content).toMatchObject({ name: newName });
|
||||
return Promise.resolve();
|
||||
});
|
||||
client.sendStateEvent = fn;
|
||||
await tree.setName(newName);
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should support inviting users to the space', async () => {
|
||||
const target = targetUser;
|
||||
const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => {
|
||||
expect(inviteRoomId).toEqual(roomId);
|
||||
expect(userId).toEqual(target);
|
||||
return Promise.resolve();
|
||||
});
|
||||
client.invite = fn;
|
||||
await tree.invite(target);
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) {
|
||||
makePowerLevels(pls);
|
||||
const fn = jest.fn()
|
||||
.mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => {
|
||||
expect(stateRoomId).toEqual(roomId);
|
||||
expect(eventType).toEqual(EventType.RoomPowerLevels);
|
||||
expect(stateKey).toEqual("");
|
||||
expect(content).toMatchObject({
|
||||
...pls,
|
||||
users: {
|
||||
[targetUser]: expectedPl,
|
||||
},
|
||||
});
|
||||
return Promise.resolve();
|
||||
});
|
||||
client.sendStateEvent = fn;
|
||||
await tree.setPermissions(targetUser, role);
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
}
|
||||
|
||||
it('should support setting Viewer permissions', () => {
|
||||
return evaluatePowerLevels({
|
||||
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
|
||||
users_default: 1024,
|
||||
}, TreePermissions.Viewer, 1024);
|
||||
});
|
||||
|
||||
it('should support setting Editor permissions', () => {
|
||||
return evaluatePowerLevels({
|
||||
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
|
||||
events_default: 1024,
|
||||
}, TreePermissions.Editor, 1024);
|
||||
});
|
||||
|
||||
it('should support setting Owner permissions', () => {
|
||||
return evaluatePowerLevels({
|
||||
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
|
||||
events: {
|
||||
[EventType.RoomPowerLevels]: 1024,
|
||||
},
|
||||
}, TreePermissions.Owner, 1024);
|
||||
});
|
||||
|
||||
it('should support demoting permissions', () => {
|
||||
return evaluatePowerLevels({
|
||||
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
|
||||
users_default: 1024,
|
||||
users: {
|
||||
[targetUser]: 2222,
|
||||
},
|
||||
}, TreePermissions.Viewer, 1024);
|
||||
});
|
||||
|
||||
it('should support promoting permissions', () => {
|
||||
return evaluatePowerLevels({
|
||||
...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
|
||||
events_default: 1024,
|
||||
users: {
|
||||
[targetUser]: 5,
|
||||
},
|
||||
}, TreePermissions.Editor, 1024);
|
||||
});
|
||||
|
||||
it('should support defaults: Viewer', () => {
|
||||
return evaluatePowerLevels({}, TreePermissions.Viewer, 0);
|
||||
});
|
||||
|
||||
it('should support defaults: Editor', () => {
|
||||
return evaluatePowerLevels({}, TreePermissions.Editor, 50);
|
||||
});
|
||||
|
||||
it('should support defaults: Owner', () => {
|
||||
return evaluatePowerLevels({}, TreePermissions.Owner, 100);
|
||||
});
|
||||
|
||||
it('should create subdirectories', async () => {
|
||||
const subspaceName = "subdirectory";
|
||||
const subspaceId = "!subspace:localhost";
|
||||
const domain = "domain.example.com";
|
||||
client.getRoom = (roomId: string) => {
|
||||
if (roomId === tree.roomId) {
|
||||
return tree.room;
|
||||
} else if (roomId === subspaceId) {
|
||||
return {} as Room; // we don't need anything important off of this
|
||||
} else {
|
||||
throw new Error("Unexpected getRoom call");
|
||||
}
|
||||
};
|
||||
client.getDomain = () => domain;
|
||||
const createFn = jest.fn().mockImplementation(async (name: string) => {
|
||||
expect(name).toEqual(subspaceName);
|
||||
return new MSC3089TreeSpace(client, subspaceId);
|
||||
});
|
||||
const sendStateFn = jest.fn()
|
||||
.mockImplementation(async (roomId: string, eventType: EventType, content: any, stateKey: string) => {
|
||||
expect([tree.roomId, subspaceId]).toContain(roomId);
|
||||
if (roomId === subspaceId) {
|
||||
expect(eventType).toEqual(EventType.SpaceParent);
|
||||
expect(stateKey).toEqual(tree.roomId);
|
||||
} else {
|
||||
expect(eventType).toEqual(EventType.SpaceChild);
|
||||
expect(stateKey).toEqual(subspaceId);
|
||||
}
|
||||
expect(content).toMatchObject({ via: [domain] });
|
||||
|
||||
// return value not used
|
||||
});
|
||||
client.unstableCreateFileTree = createFn;
|
||||
client.sendStateEvent = sendStateFn;
|
||||
|
||||
const directory = await tree.createDirectory(subspaceName);
|
||||
expect(directory).toBeDefined();
|
||||
expect(directory).not.toBeNull();
|
||||
expect(directory).not.toBe(tree);
|
||||
expect(directory.roomId).toEqual(subspaceId);
|
||||
expect(createFn).toHaveBeenCalledTimes(1);
|
||||
expect(sendStateFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
const content = expect.objectContaining({ via: [domain] });
|
||||
expect(sendStateFn).toHaveBeenCalledWith(subspaceId, EventType.SpaceParent, content, tree.roomId);
|
||||
expect(sendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, content, subspaceId);
|
||||
});
|
||||
|
||||
it('should find subdirectories', () => {
|
||||
const firstChildRoom = "!one:example.org";
|
||||
const secondChildRoom = "!two:example.org";
|
||||
const thirdChildRoom = "!three:example.org"; // to ensure it doesn't end up in the subdirectories
|
||||
room.currentState = {
|
||||
getStateEvents: (eventType: EventType, stateKey?: string) => {
|
||||
expect(eventType).toEqual(EventType.SpaceChild);
|
||||
expect(stateKey).toBeUndefined();
|
||||
return [
|
||||
// Partial implementations of Room
|
||||
{ getStateKey: () => firstChildRoom },
|
||||
{ getStateKey: () => secondChildRoom },
|
||||
{ getStateKey: () => thirdChildRoom },
|
||||
];
|
||||
},
|
||||
};
|
||||
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
|
||||
|
||||
const getFn = jest.fn().mockImplementation((roomId: string) => {
|
||||
if (roomId === thirdChildRoom) {
|
||||
throw new Error("Mock not-a-space room case called (expected)");
|
||||
}
|
||||
expect([firstChildRoom, secondChildRoom]).toContain(roomId);
|
||||
return new MSC3089TreeSpace(client, roomId);
|
||||
});
|
||||
client.unstableGetFileTreeSpace = getFn;
|
||||
|
||||
const subdirectories = tree.getDirectories();
|
||||
expect(subdirectories).toBeDefined();
|
||||
expect(subdirectories.length).toBe(2);
|
||||
expect(subdirectories[0].roomId).toBe(firstChildRoom);
|
||||
expect(subdirectories[1].roomId).toBe(secondChildRoom);
|
||||
expect(getFn).toHaveBeenCalledTimes(3);
|
||||
expect(getFn).toHaveBeenCalledWith(firstChildRoom);
|
||||
expect(getFn).toHaveBeenCalledWith(secondChildRoom);
|
||||
expect(getFn).toHaveBeenCalledWith(thirdChildRoom); // check to make sure it tried
|
||||
});
|
||||
|
||||
it('should find specific directories', () => {
|
||||
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
|
||||
|
||||
// Only mocking used API
|
||||
const firstSubdirectory = { roomId: "!first:example.org" } as any as MSC3089TreeSpace;
|
||||
const searchedSubdirectory = { roomId: "!find_me:example.org" } as any as MSC3089TreeSpace;
|
||||
const thirdSubdirectory = { roomId: "!third:example.org" } as any as MSC3089TreeSpace;
|
||||
tree.getDirectories = () => [firstSubdirectory, searchedSubdirectory, thirdSubdirectory];
|
||||
|
||||
let result = tree.getDirectory(searchedSubdirectory.roomId);
|
||||
expect(result).toBe(searchedSubdirectory);
|
||||
|
||||
result = tree.getDirectory("not a subdirectory");
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should be able to delete itself', async () => {
|
||||
const delete1 = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
const subdir1 = { delete: delete1 } as any as MSC3089TreeSpace; // mock tested bits
|
||||
|
||||
const delete2 = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
const subdir2 = { delete: delete2 } as any as MSC3089TreeSpace; // mock tested bits
|
||||
|
||||
const joinMemberId = "@join:example.org";
|
||||
const knockMemberId = "@knock:example.org";
|
||||
const inviteMemberId = "@invite:example.org";
|
||||
const leaveMemberId = "@leave:example.org";
|
||||
const banMemberId = "@ban:example.org";
|
||||
const selfUserId = "@self:example.org";
|
||||
|
||||
tree.getDirectories = () => [subdir1, subdir2];
|
||||
room.currentState = {
|
||||
getStateEvents: (eventType: EventType, stateKey?: string) => {
|
||||
expect(eventType).toEqual(EventType.RoomMember);
|
||||
expect(stateKey).toBeUndefined();
|
||||
return [
|
||||
// Partial implementations
|
||||
{ getContent: () => ({ membership: "join" }), getStateKey: () => joinMemberId },
|
||||
{ getContent: () => ({ membership: "knock" }), getStateKey: () => knockMemberId },
|
||||
{ getContent: () => ({ membership: "invite" }), getStateKey: () => inviteMemberId },
|
||||
{ getContent: () => ({ membership: "leave" }), getStateKey: () => leaveMemberId },
|
||||
{ getContent: () => ({ membership: "ban" }), getStateKey: () => banMemberId },
|
||||
|
||||
// ensure we don't kick ourselves
|
||||
{ getContent: () => ({ membership: "join" }), getStateKey: () => selfUserId },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
// These two functions are tested by input expectations, so no expectations in the function bodies
|
||||
const kickFn = jest.fn().mockImplementation((userId) => Promise.resolve());
|
||||
const leaveFn = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
client.kick = kickFn;
|
||||
client.leave = leaveFn;
|
||||
client.getUserId = () => selfUserId;
|
||||
|
||||
await tree.delete();
|
||||
|
||||
expect(delete1).toHaveBeenCalledTimes(1);
|
||||
expect(delete2).toHaveBeenCalledTimes(1);
|
||||
expect(kickFn).toHaveBeenCalledTimes(3);
|
||||
expect(kickFn).toHaveBeenCalledWith(tree.roomId, joinMemberId, expect.any(String));
|
||||
expect(kickFn).toHaveBeenCalledWith(tree.roomId, knockMemberId, expect.any(String));
|
||||
expect(kickFn).toHaveBeenCalledWith(tree.roomId, inviteMemberId, expect.any(String));
|
||||
expect(leaveFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('get and set order', () => {
|
||||
// Danger: these are partial implementations for testing purposes only
|
||||
|
||||
// @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important
|
||||
let childState: { [roomId: string]: MatrixEvent[] } = {};
|
||||
// @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important
|
||||
let parentState: MatrixEvent[] = [];
|
||||
let parentRoom: Room;
|
||||
let childTrees: MSC3089TreeSpace[];
|
||||
let rooms: { [roomId: string]: Room };
|
||||
let clientSendStateFn: jest.MockedFunction<typeof client.sendStateEvent>;
|
||||
const staticDomain = "static.example.org";
|
||||
|
||||
function addSubspace(roomId: string, createTs?: number, order?: string) {
|
||||
const content = {
|
||||
via: [staticDomain],
|
||||
};
|
||||
if (order) content['order'] = order;
|
||||
parentState.push({
|
||||
getType: () => EventType.SpaceChild,
|
||||
getStateKey: () => roomId,
|
||||
getContent: () => content,
|
||||
});
|
||||
childState[roomId] = [
|
||||
{
|
||||
getType: () => EventType.SpaceParent,
|
||||
getStateKey: () => tree.roomId,
|
||||
getContent: () => ({
|
||||
via: [staticDomain],
|
||||
}),
|
||||
},
|
||||
];
|
||||
if (createTs) {
|
||||
childState[roomId].push({
|
||||
getType: () => EventType.RoomCreate,
|
||||
getStateKey: () => "",
|
||||
getContent: () => ({}),
|
||||
getTs: () => createTs,
|
||||
});
|
||||
}
|
||||
rooms[roomId] = makeMockChildRoom(roomId);
|
||||
childTrees.push(new MSC3089TreeSpace(client, roomId));
|
||||
}
|
||||
|
||||
function expectOrder(childRoomId: string, order: number) {
|
||||
const child = childTrees.find(c => c.roomId === childRoomId);
|
||||
expect(child).toBeDefined();
|
||||
expect(child.getOrder()).toEqual(order);
|
||||
}
|
||||
|
||||
function makeMockChildRoom(roomId: string): Room {
|
||||
return {
|
||||
currentState: {
|
||||
getStateEvents: (eventType: EventType, stateKey?: string) => {
|
||||
expect([EventType.SpaceParent, EventType.RoomCreate]).toContain(eventType);
|
||||
if (eventType === EventType.RoomCreate) {
|
||||
expect(stateKey).toEqual("");
|
||||
return childState[roomId].find(e => e.getType() === EventType.RoomCreate);
|
||||
} else {
|
||||
expect(stateKey).toBeUndefined();
|
||||
return childState[roomId].filter(e => e.getType() === eventType);
|
||||
}
|
||||
},
|
||||
},
|
||||
} as Room; // partial
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
childState = {};
|
||||
parentState = [];
|
||||
parentRoom = {
|
||||
...tree.room,
|
||||
roomId: tree.roomId,
|
||||
currentState: {
|
||||
getStateEvents: (eventType: EventType, stateKey?: string) => {
|
||||
expect([
|
||||
EventType.SpaceChild,
|
||||
EventType.RoomCreate,
|
||||
EventType.SpaceParent,
|
||||
]).toContain(eventType);
|
||||
|
||||
if (eventType === EventType.RoomCreate) {
|
||||
expect(stateKey).toEqual("");
|
||||
return parentState.filter(e => e.getType() === EventType.RoomCreate)[0];
|
||||
} else {
|
||||
if (stateKey !== undefined) {
|
||||
expect(Object.keys(rooms)).toContain(stateKey);
|
||||
expect(stateKey).not.toEqual(tree.roomId);
|
||||
return parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey);
|
||||
} // else fine
|
||||
return parentState.filter(e => e.getType() === eventType);
|
||||
}
|
||||
},
|
||||
},
|
||||
} as Room;
|
||||
childTrees = [];
|
||||
rooms = {};
|
||||
rooms[tree.roomId] = parentRoom;
|
||||
(<any>tree).room = parentRoom; // override readonly
|
||||
client.getRoom = (r) => rooms[r];
|
||||
|
||||
clientSendStateFn = jest.fn()
|
||||
.mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => {
|
||||
expect(roomId).toEqual(tree.roomId);
|
||||
expect(eventType).toEqual(EventType.SpaceChild);
|
||||
expect(content).toMatchObject(expect.objectContaining({
|
||||
via: expect.any(Array),
|
||||
order: expect.any(String),
|
||||
}));
|
||||
expect(Object.keys(rooms)).toContain(stateKey);
|
||||
expect(stateKey).not.toEqual(tree.roomId);
|
||||
|
||||
const stateEvent = parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey);
|
||||
expect(stateEvent).toBeDefined();
|
||||
stateEvent.getContent = () => content;
|
||||
|
||||
return Promise.resolve(); // return value not used
|
||||
});
|
||||
client.sendStateEvent = clientSendStateFn;
|
||||
});
|
||||
|
||||
it('should know when something is top level', () => {
|
||||
const a = "!a:example.org";
|
||||
addSubspace(a);
|
||||
|
||||
expect(tree.isTopLevel).toBe(true);
|
||||
expect(childTrees[0].isTopLevel).toBe(false); // a bit of a hack to get at this, but it's fine
|
||||
});
|
||||
|
||||
it('should return -1 for top level spaces', () => {
|
||||
// The tree is what we've defined as top level, so it should work
|
||||
expect(tree.getOrder()).toEqual(-1);
|
||||
});
|
||||
|
||||
it('should throw when setting an order at the top level space', async () => {
|
||||
try {
|
||||
// The tree is what we've defined as top level, so it should work
|
||||
await tree.setOrder(2);
|
||||
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Failed to fail");
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual("Cannot set order of top level spaces currently");
|
||||
}
|
||||
});
|
||||
|
||||
it('should return a stable order for unordered children', () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(c, 3);
|
||||
addSubspace(b, 2);
|
||||
addSubspace(a, 1);
|
||||
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 1);
|
||||
expectOrder(c, 2);
|
||||
});
|
||||
|
||||
it('should return a stable order for ordered children', () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(a, 1, "Z");
|
||||
addSubspace(b, 2, "Y");
|
||||
addSubspace(c, 3, "X");
|
||||
|
||||
expectOrder(c, 0);
|
||||
expectOrder(b, 1);
|
||||
expectOrder(a, 2);
|
||||
});
|
||||
|
||||
it('should return a stable order for partially ordered children', () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
const d = "!d:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(a, 1);
|
||||
addSubspace(b, 2);
|
||||
addSubspace(c, 3, "Y");
|
||||
addSubspace(d, 4, "X");
|
||||
|
||||
expectOrder(d, 0);
|
||||
expectOrder(c, 1);
|
||||
expectOrder(b, 3); // note order diff due to room ID comparison expectation
|
||||
expectOrder(a, 2);
|
||||
});
|
||||
|
||||
it('should return a stable order if the create event timestamps are the same', () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(c, 3);
|
||||
addSubspace(b, 3); // same as C
|
||||
addSubspace(a, 3); // same as C
|
||||
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 1);
|
||||
expectOrder(c, 2);
|
||||
});
|
||||
|
||||
it('should return a stable order if there are no known create events', () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(c);
|
||||
addSubspace(b);
|
||||
addSubspace(a);
|
||||
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 1);
|
||||
expectOrder(c, 2);
|
||||
});
|
||||
|
||||
// XXX: These tests rely on `getOrder()` re-calculating and not caching values.
|
||||
|
||||
it('should allow reordering within unordered children', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(c, 3);
|
||||
addSubspace(b, 2);
|
||||
addSubspace(a, 1);
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeA = childTrees.find(c => c.roomId === a);
|
||||
expect(treeA).toBeDefined();
|
||||
await treeA.setOrder(1);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(3);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
|
||||
// Because of how the reordering works (maintain stable ordering before moving), we end up calling this
|
||||
// function twice for the same room.
|
||||
order: DEFAULT_ALPHABET[0],
|
||||
}), a);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: DEFAULT_ALPHABET[1],
|
||||
}), b);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: DEFAULT_ALPHABET[2],
|
||||
}), a);
|
||||
expectOrder(a, 1);
|
||||
expectOrder(b, 0);
|
||||
expectOrder(c, 2);
|
||||
});
|
||||
|
||||
it('should allow reordering within ordered children', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(c, 3, "Z");
|
||||
addSubspace(b, 2, "X");
|
||||
addSubspace(a, 1, "V");
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeA = childTrees.find(c => c.roomId === a);
|
||||
expect(treeA).toBeDefined();
|
||||
await treeA.setOrder(1);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'Y',
|
||||
}), a);
|
||||
expectOrder(a, 1);
|
||||
expectOrder(b, 0);
|
||||
expectOrder(c, 2);
|
||||
});
|
||||
|
||||
it('should allow reordering within partially ordered children', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
const d = "!d:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(a, 1);
|
||||
addSubspace(b, 2);
|
||||
addSubspace(c, 3, "Y");
|
||||
addSubspace(d, 4, "W");
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeA = childTrees.find(c => c.roomId === a);
|
||||
expect(treeA).toBeDefined();
|
||||
await treeA.setOrder(2);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'Z',
|
||||
}), a);
|
||||
expectOrder(a, 2);
|
||||
expectOrder(b, 3);
|
||||
expectOrder(c, 1);
|
||||
expectOrder(d, 0);
|
||||
});
|
||||
|
||||
it('should support moving upwards', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
const d = "!d:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(d, 4, "Z");
|
||||
addSubspace(c, 3, "X");
|
||||
addSubspace(b, 2, "V");
|
||||
addSubspace(a, 1, "T");
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeB = childTrees.find(c => c.roomId === b);
|
||||
expect(treeB).toBeDefined();
|
||||
await treeB.setOrder(2);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'Y',
|
||||
}), b);
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 2);
|
||||
expectOrder(c, 1);
|
||||
expectOrder(d, 3);
|
||||
});
|
||||
|
||||
it('should support moving downwards', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
const d = "!d:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(d, 4, "Z");
|
||||
addSubspace(c, 3, "X");
|
||||
addSubspace(b, 2, "V");
|
||||
addSubspace(a, 1, "T");
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeC = childTrees.find(ch => ch.roomId === c);
|
||||
expect(treeC).toBeDefined();
|
||||
await treeC.setOrder(1);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'U',
|
||||
}), c);
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 2);
|
||||
expectOrder(c, 1);
|
||||
expectOrder(d, 3);
|
||||
});
|
||||
|
||||
it('should support moving over the partial ordering boundary', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
const d = "!d:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(d, 4);
|
||||
addSubspace(c, 3);
|
||||
addSubspace(b, 2, "V");
|
||||
addSubspace(a, 1, "T");
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeB = childTrees.find(ch => ch.roomId === b);
|
||||
expect(treeB).toBeDefined();
|
||||
await treeB.setOrder(2);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(2);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'W',
|
||||
}), c);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'X',
|
||||
}), b);
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 2);
|
||||
expectOrder(c, 1);
|
||||
expectOrder(d, 3);
|
||||
});
|
||||
});
|
||||
|
||||
it('should upload files', async () => {
|
||||
const mxc = "mxc://example.org/file";
|
||||
const fileInfo = {
|
||||
mimetype: "text/plain",
|
||||
// other fields as required by encryption, but ignored here
|
||||
};
|
||||
const fileEventId = "$file";
|
||||
const fileName = "My File.txt";
|
||||
const fileContents = "This is a test file";
|
||||
|
||||
// Mock out Blob for the test environment
|
||||
(<any>global).Blob = MockBlob;
|
||||
|
||||
const uploadFn = jest.fn().mockImplementation((contents: Blob, opts: any) => {
|
||||
expect(contents).toBeInstanceOf(Blob);
|
||||
expect(contents.size).toEqual(fileContents.length);
|
||||
expect(opts).toMatchObject({
|
||||
includeFilename: false,
|
||||
onlyContentUri: true, // because the tests rely on this - we shouldn't really be testing for this.
|
||||
});
|
||||
return Promise.resolve(mxc);
|
||||
});
|
||||
client.uploadContent = uploadFn;
|
||||
|
||||
const sendMsgFn = jest.fn().mockImplementation((roomId: string, contents: any) => {
|
||||
expect(roomId).toEqual(tree.roomId);
|
||||
expect(contents).toMatchObject({
|
||||
msgtype: MsgType.File,
|
||||
body: fileName,
|
||||
url: mxc,
|
||||
file: fileInfo,
|
||||
[UNSTABLE_MSC3089_LEAF.unstable]: {}, // test to ensure we're definitely using unstable
|
||||
});
|
||||
|
||||
return Promise.resolve({ event_id: fileEventId }); // eslint-disable-line camelcase
|
||||
});
|
||||
client.sendMessage = sendMsgFn;
|
||||
|
||||
const sendStateFn = jest.fn()
|
||||
.mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => {
|
||||
expect(roomId).toEqual(tree.roomId);
|
||||
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable
|
||||
expect(stateKey).toEqual(fileEventId);
|
||||
expect(content).toMatchObject({
|
||||
active: true,
|
||||
name: fileName,
|
||||
});
|
||||
|
||||
return Promise.resolve(); // return value not used.
|
||||
});
|
||||
client.sendStateEvent = sendStateFn;
|
||||
|
||||
const buf = Uint8Array.from(Array.from(fileContents).map((_, i) => fileContents.charCodeAt(i)));
|
||||
|
||||
// We clone the file info just to make sure it doesn't get mutated for the test.
|
||||
await tree.createFile(fileName, buf, Object.assign({}, fileInfo));
|
||||
|
||||
expect(uploadFn).toHaveBeenCalledTimes(1);
|
||||
expect(sendMsgFn).toHaveBeenCalledTimes(1);
|
||||
expect(sendStateFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should support getting files', () => {
|
||||
const fileEventId = "$file";
|
||||
const fileEvent = { forTest: true }; // MatrixEvent mock
|
||||
room.currentState = {
|
||||
getStateEvents: (eventType: string, stateKey?: string) => {
|
||||
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable
|
||||
expect(stateKey).toEqual(fileEventId);
|
||||
return fileEvent;
|
||||
},
|
||||
};
|
||||
|
||||
const file = tree.getFile(fileEventId);
|
||||
expect(file).toBeDefined();
|
||||
expect(file.indexEvent).toBe(fileEvent);
|
||||
});
|
||||
|
||||
it('should return falsy for unknown files', () => {
|
||||
const fileEventId = "$file";
|
||||
room.currentState = {
|
||||
getStateEvents: (eventType: string, stateKey?: string) => {
|
||||
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable
|
||||
expect(stateKey).toEqual(fileEventId);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const file = tree.getFile(fileEventId);
|
||||
expect(file).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should list files', () => {
|
||||
const firstFile = { getContent: () => ({ active: true }) };
|
||||
const secondFile = { getContent: () => ({ active: false }) }; // deliberately inactive
|
||||
room.currentState = {
|
||||
getStateEvents: (eventType: string, stateKey?: string) => {
|
||||
expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable
|
||||
expect(stateKey).toBeUndefined();
|
||||
return [firstFile, secondFile];
|
||||
},
|
||||
};
|
||||
|
||||
const files = tree.listFiles();
|
||||
expect(files).toBeDefined();
|
||||
expect(files.length).toEqual(1);
|
||||
expect(files[0].indexEvent).toBe(firstFile);
|
||||
});
|
||||
});
|
||||
@@ -104,7 +104,7 @@ describe("Relations", function() {
|
||||
{
|
||||
const relationsCreated = new Promise(resolve => {
|
||||
targetEvent.once("Event.relationsCreated", resolve);
|
||||
})
|
||||
});
|
||||
|
||||
const timelineSet = new EventTimelineSet(room, {
|
||||
unstableClientRelationAggregation: true,
|
||||
@@ -119,7 +119,7 @@ describe("Relations", function() {
|
||||
{
|
||||
const relationsCreated = new Promise(resolve => {
|
||||
targetEvent.once("Event.relationsCreated", resolve);
|
||||
})
|
||||
});
|
||||
|
||||
const timelineSet = new EventTimelineSet(room, {
|
||||
unstableClientRelationAggregation: true,
|
||||
|
||||
@@ -1314,7 +1314,7 @@ describe("Room", function() {
|
||||
isRoomEncrypted: function() {
|
||||
return false;
|
||||
},
|
||||
_http: {
|
||||
http: {
|
||||
serverResponse,
|
||||
authedRequest: function() {
|
||||
if (this.serverResponse instanceof Error) {
|
||||
@@ -1384,7 +1384,7 @@ describe("Room", function() {
|
||||
}
|
||||
expect(hasThrown).toEqual(true);
|
||||
|
||||
client._http.serverResponse = [memberEvent];
|
||||
client.http.serverResponse = [memberEvent];
|
||||
await room.loadMembersIfNeeded();
|
||||
const memberA = room.getMember("@user_a:bar");
|
||||
expect(memberA.name).toEqual("User A");
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import * as utils from "../../src/utils";
|
||||
|
||||
describe("utils", function() {
|
||||
describe("encodeParams", function() {
|
||||
it("should url encode and concat with &s", function() {
|
||||
const params = {
|
||||
foo: "bar",
|
||||
baz: "beer@",
|
||||
};
|
||||
expect(utils.encodeParams(params)).toEqual(
|
||||
"foo=bar&baz=beer%40",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeUri", function() {
|
||||
it("should replace based on object keys and url encode", function() {
|
||||
const path = "foo/bar/%something/%here";
|
||||
const vals = {
|
||||
"%something": "baz",
|
||||
"%here": "beer@",
|
||||
};
|
||||
expect(utils.encodeUri(path, vals)).toEqual(
|
||||
"foo/bar/baz/beer%40",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeElement", function() {
|
||||
it("should remove only 1 element if there is a match", function() {
|
||||
const matchFn = function() {
|
||||
return true;
|
||||
};
|
||||
const arr = [55, 66, 77];
|
||||
utils.removeElement(arr, matchFn);
|
||||
expect(arr).toEqual([66, 77]);
|
||||
});
|
||||
it("should be able to remove in reverse order", function() {
|
||||
const matchFn = function() {
|
||||
return true;
|
||||
};
|
||||
const arr = [55, 66, 77];
|
||||
utils.removeElement(arr, matchFn, true);
|
||||
expect(arr).toEqual([55, 66]);
|
||||
});
|
||||
it("should remove nothing if the function never returns true", function() {
|
||||
const matchFn = function() {
|
||||
return false;
|
||||
};
|
||||
const arr = [55, 66, 77];
|
||||
utils.removeElement(arr, matchFn);
|
||||
expect(arr).toEqual(arr);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFunction", function() {
|
||||
it("should return true for functions", function() {
|
||||
expect(utils.isFunction([])).toBe(false);
|
||||
expect(utils.isFunction([5, 3, 7])).toBe(false);
|
||||
expect(utils.isFunction()).toBe(false);
|
||||
expect(utils.isFunction(null)).toBe(false);
|
||||
expect(utils.isFunction({})).toBe(false);
|
||||
expect(utils.isFunction("foo")).toBe(false);
|
||||
expect(utils.isFunction(555)).toBe(false);
|
||||
|
||||
expect(utils.isFunction(function() {})).toBe(true);
|
||||
const s = { foo: function() {} };
|
||||
expect(utils.isFunction(s.foo)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkObjectHasKeys", function() {
|
||||
it("should throw for missing keys", function() {
|
||||
expect(function() {
|
||||
utils.checkObjectHasKeys({}, ["foo"]);
|
||||
}).toThrow();
|
||||
expect(function() {
|
||||
utils.checkObjectHasKeys({
|
||||
foo: "bar",
|
||||
}, ["foo"]);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkObjectHasNoAdditionalKeys", function() {
|
||||
it("should throw for extra keys", function() {
|
||||
expect(function() {
|
||||
utils.checkObjectHasNoAdditionalKeys({
|
||||
foo: "bar",
|
||||
baz: 4,
|
||||
}, ["foo"]);
|
||||
}).toThrow();
|
||||
|
||||
expect(function() {
|
||||
utils.checkObjectHasNoAdditionalKeys({
|
||||
foo: "bar",
|
||||
}, ["foo"]);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deepCompare", function() {
|
||||
const assert = {
|
||||
isTrue: function(x) {
|
||||
expect(x).toBe(true);
|
||||
},
|
||||
isFalse: function(x) {
|
||||
expect(x).toBe(false);
|
||||
},
|
||||
};
|
||||
|
||||
it("should handle primitives", function() {
|
||||
assert.isTrue(utils.deepCompare(null, null));
|
||||
assert.isFalse(utils.deepCompare(null, undefined));
|
||||
assert.isTrue(utils.deepCompare("hi", "hi"));
|
||||
assert.isTrue(utils.deepCompare(5, 5));
|
||||
assert.isFalse(utils.deepCompare(5, 10));
|
||||
});
|
||||
|
||||
it("should handle regexps", function() {
|
||||
assert.isTrue(utils.deepCompare(/abc/, /abc/));
|
||||
assert.isFalse(utils.deepCompare(/abc/, /123/));
|
||||
const r = /abc/;
|
||||
assert.isTrue(utils.deepCompare(r, r));
|
||||
});
|
||||
|
||||
it("should handle dates", function() {
|
||||
assert.isTrue(utils.deepCompare(new Date("2011-03-31"),
|
||||
new Date("2011-03-31")));
|
||||
assert.isFalse(utils.deepCompare(new Date("2011-03-31"),
|
||||
new Date("1970-01-01")));
|
||||
});
|
||||
|
||||
it("should handle arrays", function() {
|
||||
assert.isTrue(utils.deepCompare([], []));
|
||||
assert.isTrue(utils.deepCompare([1, 2], [1, 2]));
|
||||
assert.isFalse(utils.deepCompare([1, 2], [2, 1]));
|
||||
assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3]));
|
||||
});
|
||||
|
||||
it("should handle simple objects", function() {
|
||||
assert.isTrue(utils.deepCompare({}, {}));
|
||||
assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 }));
|
||||
assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 }));
|
||||
assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 }));
|
||||
|
||||
assert.isTrue(utils.deepCompare({ 1: { name: "mhc", age: 28 },
|
||||
2: { name: "arb", age: 26 } },
|
||||
{ 1: { name: "mhc", age: 28 },
|
||||
2: { name: "arb", age: 26 } }));
|
||||
|
||||
assert.isFalse(utils.deepCompare({ 1: { name: "mhc", age: 28 },
|
||||
2: { name: "arb", age: 26 } },
|
||||
{ 1: { name: "mhc", age: 28 },
|
||||
2: { name: "arb", age: 27 } }));
|
||||
|
||||
assert.isFalse(utils.deepCompare({}, null));
|
||||
assert.isFalse(utils.deepCompare({}, undefined));
|
||||
});
|
||||
|
||||
it("should handle functions", function() {
|
||||
// no two different function is equal really, they capture their
|
||||
// context variables so even if they have same toString(), they
|
||||
// won't have same functionality
|
||||
const func = function(x) {
|
||||
return true;
|
||||
};
|
||||
const func2 = function(x) {
|
||||
return true;
|
||||
};
|
||||
assert.isTrue(utils.deepCompare(func, func));
|
||||
assert.isFalse(utils.deepCompare(func, func2));
|
||||
assert.isTrue(utils.deepCompare({ a: { b: func } }, { a: { b: func } }));
|
||||
assert.isFalse(utils.deepCompare({ a: { b: func } }, { a: { b: func2 } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("extend", function() {
|
||||
const SOURCE = { "prop2": 1, "string2": "x", "newprop": "new" };
|
||||
|
||||
it("should extend", function() {
|
||||
const target = {
|
||||
"prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo",
|
||||
};
|
||||
const merged = {
|
||||
"prop1": 5, "prop2": 1, "string1": "baz", "string2": "x",
|
||||
"newprop": "new",
|
||||
};
|
||||
const sourceOrig = JSON.stringify(SOURCE);
|
||||
|
||||
utils.extend(target, SOURCE);
|
||||
expect(JSON.stringify(target)).toEqual(JSON.stringify(merged));
|
||||
|
||||
// check the originial wasn't modified
|
||||
expect(JSON.stringify(SOURCE)).toEqual(sourceOrig);
|
||||
});
|
||||
|
||||
it("should ignore null", function() {
|
||||
const target = {
|
||||
"prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo",
|
||||
};
|
||||
const merged = {
|
||||
"prop1": 5, "prop2": 1, "string1": "baz", "string2": "x",
|
||||
"newprop": "new",
|
||||
};
|
||||
const sourceOrig = JSON.stringify(SOURCE);
|
||||
|
||||
utils.extend(target, null, SOURCE);
|
||||
expect(JSON.stringify(target)).toEqual(JSON.stringify(merged));
|
||||
|
||||
// check the originial wasn't modified
|
||||
expect(JSON.stringify(SOURCE)).toEqual(sourceOrig);
|
||||
});
|
||||
|
||||
it("should handle properties created with defineProperties", function() {
|
||||
const source = Object.defineProperties({}, {
|
||||
"enumerableProp": {
|
||||
get: function() {
|
||||
return true;
|
||||
},
|
||||
enumerable: true,
|
||||
},
|
||||
"nonenumerableProp": {
|
||||
get: function() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const target = {};
|
||||
utils.extend(target, source);
|
||||
expect(target.enumerableProp).toBe(true);
|
||||
expect(target.nonenumerableProp).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkPromises", function() {
|
||||
it("should execute promises in chunks", async function() {
|
||||
let promiseCount = 0;
|
||||
|
||||
function fn1() {
|
||||
return new Promise(async function(resolve, reject) {
|
||||
await utils.sleep(1);
|
||||
expect(promiseCount).toEqual(0);
|
||||
++promiseCount;
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function fn2() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
expect(promiseCount).toEqual(1);
|
||||
++promiseCount;
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
await utils.chunkPromises([fn1, fn2], 1);
|
||||
expect(promiseCount).toEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
import * as utils from "../../src/utils";
|
||||
import {
|
||||
alphabetPad,
|
||||
averageBetweenStrings,
|
||||
baseToString,
|
||||
DEFAULT_ALPHABET,
|
||||
lexicographicCompare,
|
||||
nextString,
|
||||
prevString,
|
||||
stringToBase,
|
||||
} from "../../src/utils";
|
||||
import { logger } from "../../src/logger";
|
||||
|
||||
// TODO: Fix types throughout
|
||||
|
||||
describe("utils", function() {
|
||||
describe("encodeParams", function() {
|
||||
it("should url encode and concat with &s", function() {
|
||||
const params = {
|
||||
foo: "bar",
|
||||
baz: "beer@",
|
||||
};
|
||||
expect(utils.encodeParams(params)).toEqual(
|
||||
"foo=bar&baz=beer%40",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeUri", function() {
|
||||
it("should replace based on object keys and url encode", function() {
|
||||
const path = "foo/bar/%something/%here";
|
||||
const vals = {
|
||||
"%something": "baz",
|
||||
"%here": "beer@",
|
||||
};
|
||||
expect(utils.encodeUri(path, vals)).toEqual(
|
||||
"foo/bar/baz/beer%40",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeElement", function() {
|
||||
it("should remove only 1 element if there is a match", function() {
|
||||
const matchFn = function() {
|
||||
return true;
|
||||
};
|
||||
const arr = [55, 66, 77];
|
||||
utils.removeElement(arr, matchFn);
|
||||
expect(arr).toEqual([66, 77]);
|
||||
});
|
||||
it("should be able to remove in reverse order", function() {
|
||||
const matchFn = function() {
|
||||
return true;
|
||||
};
|
||||
const arr = [55, 66, 77];
|
||||
utils.removeElement(arr, matchFn, true);
|
||||
expect(arr).toEqual([55, 66]);
|
||||
});
|
||||
it("should remove nothing if the function never returns true", function() {
|
||||
const matchFn = function() {
|
||||
return false;
|
||||
};
|
||||
const arr = [55, 66, 77];
|
||||
utils.removeElement(arr, matchFn);
|
||||
expect(arr).toEqual(arr);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFunction", function() {
|
||||
it("should return true for functions", function() {
|
||||
expect(utils.isFunction([])).toBe(false);
|
||||
expect(utils.isFunction([5, 3, 7])).toBe(false);
|
||||
expect(utils.isFunction(undefined)).toBe(false);
|
||||
expect(utils.isFunction(null)).toBe(false);
|
||||
expect(utils.isFunction({})).toBe(false);
|
||||
expect(utils.isFunction("foo")).toBe(false);
|
||||
expect(utils.isFunction(555)).toBe(false);
|
||||
|
||||
expect(utils.isFunction(function() {})).toBe(true);
|
||||
const s = { foo: function() {} };
|
||||
expect(utils.isFunction(s.foo)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkObjectHasKeys", function() {
|
||||
it("should throw for missing keys", function() {
|
||||
expect(function() {
|
||||
utils.checkObjectHasKeys({}, ["foo"]);
|
||||
}).toThrow();
|
||||
expect(function() {
|
||||
utils.checkObjectHasKeys({
|
||||
foo: "bar",
|
||||
}, ["foo"]);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkObjectHasNoAdditionalKeys", function() {
|
||||
it("should throw for extra keys", function() {
|
||||
expect(function() {
|
||||
utils.checkObjectHasNoAdditionalKeys({ foo: "bar", baz: 4 }, ["foo"]);
|
||||
}).toThrow();
|
||||
|
||||
expect(function() {
|
||||
utils.checkObjectHasNoAdditionalKeys({ foo: "bar" }, ["foo"]);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deepCompare", function() {
|
||||
const assert = {
|
||||
isTrue: function(x) {
|
||||
expect(x).toBe(true);
|
||||
},
|
||||
isFalse: function(x) {
|
||||
expect(x).toBe(false);
|
||||
},
|
||||
};
|
||||
|
||||
it("should handle primitives", function() {
|
||||
assert.isTrue(utils.deepCompare(null, null));
|
||||
assert.isFalse(utils.deepCompare(null, undefined));
|
||||
assert.isTrue(utils.deepCompare("hi", "hi"));
|
||||
assert.isTrue(utils.deepCompare(5, 5));
|
||||
assert.isFalse(utils.deepCompare(5, 10));
|
||||
});
|
||||
|
||||
it("should handle regexps", function() {
|
||||
assert.isTrue(utils.deepCompare(/abc/, /abc/));
|
||||
assert.isFalse(utils.deepCompare(/abc/, /123/));
|
||||
const r = /abc/;
|
||||
assert.isTrue(utils.deepCompare(r, r));
|
||||
});
|
||||
|
||||
it("should handle dates", function() {
|
||||
assert.isTrue(utils.deepCompare(new Date("2011-03-31"), new Date("2011-03-31")));
|
||||
assert.isFalse(utils.deepCompare(new Date("2011-03-31"), new Date("1970-01-01")));
|
||||
});
|
||||
|
||||
it("should handle arrays", function() {
|
||||
assert.isTrue(utils.deepCompare([], []));
|
||||
assert.isTrue(utils.deepCompare([1, 2], [1, 2]));
|
||||
assert.isFalse(utils.deepCompare([1, 2], [2, 1]));
|
||||
assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3]));
|
||||
});
|
||||
|
||||
it("should handle simple objects", function() {
|
||||
assert.isTrue(utils.deepCompare({}, {}));
|
||||
assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 }));
|
||||
assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 }));
|
||||
assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 }));
|
||||
|
||||
assert.isTrue(utils.deepCompare({
|
||||
1: { name: "mhc", age: 28 },
|
||||
2: { name: "arb", age: 26 },
|
||||
}, {
|
||||
1: { name: "mhc", age: 28 },
|
||||
2: { name: "arb", age: 26 },
|
||||
}));
|
||||
|
||||
assert.isFalse(utils.deepCompare({
|
||||
1: { name: "mhc", age: 28 },
|
||||
2: { name: "arb", age: 26 },
|
||||
}, {
|
||||
1: { name: "mhc", age: 28 },
|
||||
2: { name: "arb", age: 27 },
|
||||
}));
|
||||
|
||||
assert.isFalse(utils.deepCompare({}, null));
|
||||
assert.isFalse(utils.deepCompare({}, undefined));
|
||||
});
|
||||
|
||||
it("should handle functions", function() {
|
||||
// no two different function is equal really, they capture their
|
||||
// context variables so even if they have same toString(), they
|
||||
// won't have same functionality
|
||||
const func = function(x) {
|
||||
return true;
|
||||
};
|
||||
const func2 = function(x) {
|
||||
return true;
|
||||
};
|
||||
assert.isTrue(utils.deepCompare(func, func));
|
||||
assert.isFalse(utils.deepCompare(func, func2));
|
||||
assert.isTrue(utils.deepCompare({ a: { b: func } }, { a: { b: func } }));
|
||||
assert.isFalse(utils.deepCompare({ a: { b: func } }, { a: { b: func2 } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("extend", function() {
|
||||
const SOURCE = { "prop2": 1, "string2": "x", "newprop": "new" };
|
||||
|
||||
it("should extend", function() {
|
||||
const target = {
|
||||
"prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo",
|
||||
};
|
||||
const merged = {
|
||||
"prop1": 5, "prop2": 1, "string1": "baz", "string2": "x",
|
||||
"newprop": "new",
|
||||
};
|
||||
const sourceOrig = JSON.stringify(SOURCE);
|
||||
|
||||
utils.extend(target, SOURCE);
|
||||
expect(JSON.stringify(target)).toEqual(JSON.stringify(merged));
|
||||
|
||||
// check the originial wasn't modified
|
||||
expect(JSON.stringify(SOURCE)).toEqual(sourceOrig);
|
||||
});
|
||||
|
||||
it("should ignore null", function() {
|
||||
const target = {
|
||||
"prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo",
|
||||
};
|
||||
const merged = {
|
||||
"prop1": 5, "prop2": 1, "string1": "baz", "string2": "x",
|
||||
"newprop": "new",
|
||||
};
|
||||
const sourceOrig = JSON.stringify(SOURCE);
|
||||
|
||||
utils.extend(target, null, SOURCE);
|
||||
expect(JSON.stringify(target)).toEqual(JSON.stringify(merged));
|
||||
|
||||
// check the originial wasn't modified
|
||||
expect(JSON.stringify(SOURCE)).toEqual(sourceOrig);
|
||||
});
|
||||
|
||||
it("should handle properties created with defineProperties", function() {
|
||||
const source = Object.defineProperties({}, {
|
||||
"enumerableProp": {
|
||||
get: function() {
|
||||
return true;
|
||||
},
|
||||
enumerable: true,
|
||||
},
|
||||
"nonenumerableProp": {
|
||||
get: function() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Fix type
|
||||
const target: any = {};
|
||||
utils.extend(target, source);
|
||||
expect(target.enumerableProp).toBe(true);
|
||||
expect(target.nonenumerableProp).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkPromises", function() {
|
||||
it("should execute promises in chunks", async function() {
|
||||
let promiseCount = 0;
|
||||
|
||||
async function fn1() {
|
||||
await utils.sleep(1);
|
||||
expect(promiseCount).toEqual(0);
|
||||
++promiseCount;
|
||||
}
|
||||
|
||||
async function fn2() {
|
||||
expect(promiseCount).toEqual(1);
|
||||
++promiseCount;
|
||||
}
|
||||
|
||||
await utils.chunkPromises([fn1, fn2], 1);
|
||||
expect(promiseCount).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_ALPHABET', () => {
|
||||
it('should be usefully printable ASCII in order', () => {
|
||||
expect(DEFAULT_ALPHABET).toEqual(
|
||||
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alphabetPad', () => {
|
||||
it('should pad to the alphabet length', () => {
|
||||
const len = 12;
|
||||
expect(alphabetPad("a", len)).toEqual("a" + ("".padEnd(len - 1, DEFAULT_ALPHABET[0])));
|
||||
expect(alphabetPad("a", len, "123")).toEqual("a" + ("".padEnd(len - 1, '1')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseToString', () => {
|
||||
it('should calculate the appropriate string from numbers', () => {
|
||||
// Verify the whole alphabet
|
||||
for (let i = BigInt(1); i <= DEFAULT_ALPHABET.length; i++) {
|
||||
logger.log({ i }); // for debugging
|
||||
expect(baseToString(i)).toEqual(DEFAULT_ALPHABET[Number(i) - 1]);
|
||||
}
|
||||
|
||||
// Just quickly double check that repeated characters aren't treated as padding, particularly
|
||||
// at the beginning of the alphabet where they are most vulnerable to this behaviour.
|
||||
expect(baseToString(BigInt(1))).toEqual(DEFAULT_ALPHABET[0].repeat(1));
|
||||
expect(baseToString(BigInt(96))).toEqual(DEFAULT_ALPHABET[0].repeat(2));
|
||||
expect(baseToString(BigInt(9121))).toEqual(DEFAULT_ALPHABET[0].repeat(3));
|
||||
expect(baseToString(BigInt(866496))).toEqual(DEFAULT_ALPHABET[0].repeat(4));
|
||||
expect(baseToString(BigInt(82317121))).toEqual(DEFAULT_ALPHABET[0].repeat(5));
|
||||
expect(baseToString(BigInt(7820126496))).toEqual(DEFAULT_ALPHABET[0].repeat(6));
|
||||
|
||||
expect(baseToString(BigInt(10))).toEqual(DEFAULT_ALPHABET[9]);
|
||||
expect(baseToString(BigInt(10), "abcdefghijklmnopqrstuvwxyz")).toEqual('j');
|
||||
expect(baseToString(BigInt(6337))).toEqual("ab");
|
||||
expect(baseToString(BigInt(80), "abcdefghijklmnopqrstuvwxyz")).toEqual('cb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringToBase', () => {
|
||||
it('should calculate the appropriate number for a string', () => {
|
||||
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(1))).toEqual(BigInt(1));
|
||||
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(2))).toEqual(BigInt(96));
|
||||
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(3))).toEqual(BigInt(9121));
|
||||
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(4))).toEqual(BigInt(866496));
|
||||
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(5))).toEqual(BigInt(82317121));
|
||||
expect(stringToBase(DEFAULT_ALPHABET[0].repeat(6))).toEqual(BigInt(7820126496));
|
||||
expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(1));
|
||||
expect(stringToBase("a")).toEqual(BigInt(66));
|
||||
expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(3));
|
||||
expect(stringToBase("ab")).toEqual(BigInt(6337));
|
||||
expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(80));
|
||||
});
|
||||
});
|
||||
|
||||
describe('averageBetweenStrings', () => {
|
||||
it('should average appropriately', () => {
|
||||
expect(averageBetweenStrings(" ", "!!")).toEqual(" P");
|
||||
expect(averageBetweenStrings(" ", "!")).toEqual(" ");
|
||||
expect(averageBetweenStrings('A', 'B')).toEqual('A ');
|
||||
expect(averageBetweenStrings('AA', 'BB')).toEqual('Aq');
|
||||
expect(averageBetweenStrings('A', 'z')).toEqual(']');
|
||||
expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('m');
|
||||
expect(averageBetweenStrings('AA', 'zz')).toEqual('^.');
|
||||
expect(averageBetweenStrings('aa', 'zz', "abcdefghijklmnopqrstuvwxyz")).toEqual('mz');
|
||||
expect(averageBetweenStrings('cat', 'doggo')).toEqual("d9>Cw");
|
||||
expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("cumqh");
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextString', () => {
|
||||
it('should find the next string appropriately', () => {
|
||||
expect(nextString('A')).toEqual('B');
|
||||
expect(nextString('b', 'abcdefghijklmnopqrstuvwxyz')).toEqual('c');
|
||||
expect(nextString('cat')).toEqual('cau');
|
||||
expect(nextString('cat', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cau');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prevString', () => {
|
||||
it('should find the next string appropriately', () => {
|
||||
expect(prevString('B')).toEqual('A');
|
||||
expect(prevString('c', 'abcdefghijklmnopqrstuvwxyz')).toEqual('b');
|
||||
expect(prevString('cau')).toEqual('cat');
|
||||
expect(prevString('cau', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cat');
|
||||
});
|
||||
});
|
||||
|
||||
// Let's just ensure the ordering is sensible for lexicographic ordering
|
||||
describe('string averaging unified', () => {
|
||||
it('should be truly previous and next', () => {
|
||||
let midpoint = "cat";
|
||||
|
||||
// We run this test 100 times to ensure we end up with a sane sequence.
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const next = nextString(midpoint);
|
||||
const prev = prevString(midpoint);
|
||||
logger.log({ i, midpoint, next, prev }); // for test debugging
|
||||
|
||||
expect(lexicographicCompare(midpoint, next) < 0).toBe(true);
|
||||
expect(lexicographicCompare(midpoint, prev) > 0).toBe(true);
|
||||
expect(averageBetweenStrings(prev, next)).toBe(midpoint);
|
||||
|
||||
midpoint = next;
|
||||
}
|
||||
});
|
||||
|
||||
it('should roll over', () => {
|
||||
const lastAlpha = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1];
|
||||
const firstAlpha = DEFAULT_ALPHABET[0];
|
||||
|
||||
const highRoll = firstAlpha + firstAlpha;
|
||||
const lowRoll = lastAlpha;
|
||||
|
||||
expect(nextString(lowRoll)).toEqual(highRoll);
|
||||
expect(prevString(highRoll)).toEqual(lowRoll);
|
||||
});
|
||||
|
||||
it('should be reversible on small strings', () => {
|
||||
// Large scale reversibility is tested for max space order value
|
||||
const input = "cats";
|
||||
expect(prevString(nextString(input))).toEqual(input);
|
||||
});
|
||||
|
||||
// We want to explicitly make sure that Space order values are supported and roll appropriately
|
||||
it('should properly handle rolling over at 50 characters', () => {
|
||||
// Note: we also test reversibility of large strings here.
|
||||
|
||||
const maxSpaceValue = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1].repeat(50);
|
||||
const fiftyFirstChar = DEFAULT_ALPHABET[0].repeat(51);
|
||||
|
||||
expect(nextString(maxSpaceValue)).toBe(fiftyFirstChar);
|
||||
expect(prevString(fiftyFirstChar)).toBe(maxSpaceValue);
|
||||
|
||||
// We're testing that the rollover happened, which means that the next string come before
|
||||
// the maximum space order value lexicographically.
|
||||
expect(lexicographicCompare(maxSpaceValue, fiftyFirstChar) > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lexicographicCompare', () => {
|
||||
it('should work', () => {
|
||||
// Simple tests
|
||||
expect(lexicographicCompare('a', 'b') < 0).toBe(true);
|
||||
expect(lexicographicCompare('ab', 'b') < 0).toBe(true);
|
||||
expect(lexicographicCompare('cat', 'dog') < 0).toBe(true);
|
||||
|
||||
// Simple tests (reversed)
|
||||
expect(lexicographicCompare('b', 'a') > 0).toBe(true);
|
||||
expect(lexicographicCompare('b', 'ab') > 0).toBe(true);
|
||||
expect(lexicographicCompare('dog', 'cat') > 0).toBe(true);
|
||||
|
||||
// Simple equality tests
|
||||
expect(lexicographicCompare('a', 'a') === 0).toBe(true);
|
||||
expect(lexicographicCompare('A', 'A') === 0).toBe(true);
|
||||
|
||||
// ASCII rule testing
|
||||
expect(lexicographicCompare('A', 'a') < 0).toBe(true);
|
||||
expect(lexicographicCompare('a', 'A') > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export interface IIdentityServerProvider {
|
||||
/**
|
||||
* Gets an access token for use against the identity server,
|
||||
* for the associated client.
|
||||
* @returns {Promise<string>} Resolves to the access token.
|
||||
*/
|
||||
getAccessToken(): Promise<string>;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { UnstableValue } from "../NamespacedValue";
|
||||
|
||||
export enum EventType {
|
||||
// Room state events
|
||||
RoomCanonicalAlias = "m.room.canonical_alias",
|
||||
@@ -71,6 +73,7 @@ export enum EventType {
|
||||
// Room account_data events
|
||||
FullyRead = "m.fully_read",
|
||||
Tag = "m.tag",
|
||||
SpaceOrder = "org.matrix.msc3230.space_order", // MSC3230
|
||||
|
||||
// User account_data events
|
||||
PushRules = "m.push_rules",
|
||||
@@ -100,3 +103,53 @@ export const RoomCreateTypeField = "type";
|
||||
export enum RoomType {
|
||||
Space = "m.space",
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifier for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088)
|
||||
* room purpose. Note that this reference is UNSTABLE and subject to breaking changes,
|
||||
* including its eventual removal.
|
||||
*/
|
||||
export const UNSTABLE_MSC3088_PURPOSE = new UnstableValue("m.room.purpose", "org.matrix.msc3088.purpose");
|
||||
|
||||
/**
|
||||
* Enabled flag for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088)
|
||||
* room purpose. Note that this reference is UNSTABLE and subject to breaking changes,
|
||||
* including its eventual removal.
|
||||
*/
|
||||
export const UNSTABLE_MSC3088_ENABLED = new UnstableValue("m.enabled", "org.matrix.msc3088.enabled");
|
||||
|
||||
/**
|
||||
* Subtype for an [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room.
|
||||
* Note that this reference is UNSTABLE and subject to breaking changes, including its
|
||||
* eventual removal.
|
||||
*/
|
||||
export const UNSTABLE_MSC3089_TREE_SUBTYPE = new UnstableValue("m.data_tree", "org.matrix.msc3089.data_tree");
|
||||
|
||||
/**
|
||||
* Leaf type for an event in a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room.
|
||||
* Note that this reference is UNSTABLE and subject to breaking changes, including its
|
||||
* eventual removal.
|
||||
*/
|
||||
export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc3089.leaf");
|
||||
|
||||
/**
|
||||
* Branch (Leaf Reference) type for the index approach in a
|
||||
* [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. Note that this reference is
|
||||
* UNSTABLE and subject to breaking changes, including its eventual removal.
|
||||
*/
|
||||
export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch");
|
||||
|
||||
export interface IEncryptedFile {
|
||||
url: string;
|
||||
mimetype?: string;
|
||||
key: {
|
||||
alg: string;
|
||||
key_ops: string[]; // eslint-disable-line camelcase
|
||||
kty: string;
|
||||
k: string;
|
||||
ext: boolean;
|
||||
};
|
||||
iv: string;
|
||||
hashes: {[alg: string]: string};
|
||||
v: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export interface IImageInfo {
|
||||
size?: number;
|
||||
mimetype?: string;
|
||||
thumbnail_info?: { // eslint-disable-line camelcase
|
||||
w?: number;
|
||||
h?: number;
|
||||
size?: number;
|
||||
mimetype?: string;
|
||||
};
|
||||
w?: number;
|
||||
h?: number;
|
||||
}
|
||||
|
||||
export enum Visibility {
|
||||
Public = "public",
|
||||
Private = "private",
|
||||
}
|
||||
|
||||
export enum Preset {
|
||||
PrivateChat = "private_chat",
|
||||
TrustedPrivateChat = "trusted_private_chat",
|
||||
PublicChat = "public_chat",
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Callback } from "../client";
|
||||
import { Preset, Visibility } from "./partials";
|
||||
|
||||
// allow camelcase as these are things go onto the wire
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
export interface IJoinRoomOpts {
|
||||
/**
|
||||
* True to do a room initial sync on the resulting
|
||||
* room. If false, the <strong>returned Room object will have no current state.
|
||||
* </strong> Default: true.
|
||||
*/
|
||||
syncRoom?: boolean;
|
||||
|
||||
/**
|
||||
* If the caller has a keypair 3pid invite, the signing URL is passed in this parameter.
|
||||
*/
|
||||
inviteSignUrl?: string;
|
||||
|
||||
/**
|
||||
* The server names to try and join through in addition to those that are automatically chosen.
|
||||
*/
|
||||
viaServers?: string[];
|
||||
}
|
||||
|
||||
export interface IRedactOpts {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ISendEventResponse {
|
||||
event_id: string;
|
||||
}
|
||||
|
||||
export interface IPresenceOpts {
|
||||
presence: "online" | "offline" | "unavailable";
|
||||
status_msg?: string;
|
||||
}
|
||||
|
||||
export interface IPaginateOpts {
|
||||
backwards?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface IGuestAccessOpts {
|
||||
allowJoin: boolean;
|
||||
allowRead: boolean;
|
||||
}
|
||||
|
||||
export interface ISearchOpts {
|
||||
keys?: string[];
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface IEventSearchOpts {
|
||||
filter: any; // TODO: Types
|
||||
term: string;
|
||||
}
|
||||
|
||||
export interface IInvite3PID {
|
||||
id_server: string;
|
||||
id_access_token?: string; // this gets injected by the js-sdk
|
||||
medium: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface ICreateRoomStateEvent {
|
||||
type: string;
|
||||
state_key?: string; // defaults to an empty string
|
||||
content: object;
|
||||
}
|
||||
|
||||
export interface ICreateRoomOpts {
|
||||
room_alias_name?: string;
|
||||
visibility?: Visibility;
|
||||
name?: string;
|
||||
topic?: string;
|
||||
preset?: Preset;
|
||||
power_level_content_override?: object;
|
||||
creation_content?: object;
|
||||
initial_state?: ICreateRoomStateEvent[];
|
||||
invite?: string[];
|
||||
invite_3pid?: IInvite3PID[];
|
||||
is_direct?: boolean;
|
||||
room_version?: string;
|
||||
}
|
||||
|
||||
export interface IRoomDirectoryOptions {
|
||||
server?: string;
|
||||
limit?: number;
|
||||
since?: string;
|
||||
|
||||
// TODO: Proper types
|
||||
filter?: any & {generic_search_term: string};
|
||||
}
|
||||
|
||||
export interface IUploadOpts {
|
||||
name?: string;
|
||||
includeFilename?: boolean;
|
||||
type?: string;
|
||||
rawResponse?: boolean;
|
||||
onlyContentUri?: boolean;
|
||||
callback?: Callback;
|
||||
progressHandler?: (state: {loaded: number, total: number}) => void;
|
||||
}
|
||||
|
||||
/* eslint-enable camelcase */
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export interface ISignatures {
|
||||
[entity: string]: {
|
||||
[keyId: string]: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a simple Matrix namespaced value. This will assume that if a stable prefix
|
||||
* is provided that the stable prefix should be used when representing the identifier.
|
||||
*/
|
||||
export class NamespacedValue<S extends string, U extends string> {
|
||||
// Stable is optional, but one of the two parameters is required, hence the weird-looking types.
|
||||
// Goal is to to have developers explicitly say there is no stable value (if applicable).
|
||||
public constructor(public readonly stable: S | null | undefined, public readonly unstable?: U) {
|
||||
if (!this.unstable && !this.stable) {
|
||||
throw new Error("One of stable or unstable values must be supplied");
|
||||
}
|
||||
}
|
||||
|
||||
public get name(): U | S {
|
||||
if (this.stable) {
|
||||
return this.stable;
|
||||
}
|
||||
return this.unstable;
|
||||
}
|
||||
|
||||
public get altName(): U | S | null {
|
||||
if (!this.stable) {
|
||||
return null;
|
||||
}
|
||||
return this.unstable;
|
||||
}
|
||||
|
||||
public matches(val: string): boolean {
|
||||
return this.name === val || this.altName === val;
|
||||
}
|
||||
|
||||
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
|
||||
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
|
||||
public findIn<T>(obj: any): T {
|
||||
let val: T;
|
||||
if (this.name) {
|
||||
val = obj?.[this.name];
|
||||
}
|
||||
if (!val && this.altName) {
|
||||
val = obj?.[this.altName];
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
public includedIn(arr: any[]): boolean {
|
||||
let included = false;
|
||||
if (this.name) {
|
||||
included = arr.includes(this.name);
|
||||
}
|
||||
if (!included && this.altName) {
|
||||
included = arr.includes(this.altName);
|
||||
}
|
||||
return included;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a namespaced value which prioritizes the unstable value over the stable
|
||||
* value.
|
||||
*/
|
||||
export class UnstableValue<S extends string, U extends string> extends NamespacedValue<S, U> {
|
||||
// Note: Constructor difference is that `unstable` is *required*.
|
||||
public constructor(stable: S, unstable: U) {
|
||||
super(stable, unstable);
|
||||
if (!this.unstable) {
|
||||
throw new Error("Unstable value must be supplied");
|
||||
}
|
||||
}
|
||||
|
||||
public get name(): U {
|
||||
return this.unstable;
|
||||
}
|
||||
|
||||
public get altName(): S {
|
||||
return this.stable;
|
||||
}
|
||||
}
|
||||
-2427
File diff suppressed because it is too large
Load Diff
-6057
File diff suppressed because it is too large
Load Diff
+8472
File diff suppressed because it is too large
Load Diff
@@ -725,7 +725,7 @@ export function createCryptoStoreCacheCallbacks(store, olmdevice) {
|
||||
/**
|
||||
* Request cross-signing keys from another device during verification.
|
||||
*
|
||||
* @param {module:base-apis~MatrixBaseApis} baseApis base Matrix API interface
|
||||
* @param {MatrixClient} baseApis base Matrix API interface
|
||||
* @param {string} userId The user ID being verified
|
||||
* @param {string} deviceId The device ID being verified
|
||||
*/
|
||||
@@ -739,7 +739,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId)
|
||||
// it. We return here in order to test.
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = baseApis;
|
||||
const original = client._crypto._crossSigningInfo;
|
||||
const original = client.crypto._crossSigningInfo;
|
||||
|
||||
// We already have all of the infrastructure we need to validate and
|
||||
// cache cross-signing keys, so instead of replicating that, here we set
|
||||
@@ -775,7 +775,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId)
|
||||
|
||||
// also request and cache the key backup key
|
||||
const backupKeyPromise = new Promise(async resolve => {
|
||||
const cachedKey = await client._crypto.getSessionBackupPrivateKey();
|
||||
const cachedKey = await client.crypto.getSessionBackupPrivateKey();
|
||||
if (!cachedKey) {
|
||||
logger.info("No cached backup key found. Requesting...");
|
||||
const secretReq = client.requestSecret(
|
||||
@@ -785,7 +785,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId)
|
||||
logger.info("Got key backup key, decoding...");
|
||||
const decodedKey = decodeBase64(base64Key);
|
||||
logger.info("Decoded backup key, storing...");
|
||||
client._crypto.storeSessionBackupPrivateKey(
|
||||
client.crypto.storeSessionBackupPrivateKey(
|
||||
Uint8Array.from(decodedKey),
|
||||
);
|
||||
logger.info("Backup key stored. Starting backup restore...");
|
||||
|
||||
@@ -204,7 +204,7 @@ export class EncryptionSetupOperation {
|
||||
// The backup is trusted because the user provided the private key.
|
||||
// Sign the backup with the cross signing key so the key backup can
|
||||
// be trusted via cross-signing.
|
||||
await baseApis._http.authedRequest(
|
||||
await baseApis.http.authedRequest(
|
||||
undefined, "PUT", "/room_keys/version/" + this._keyBackupInfo.version,
|
||||
undefined, {
|
||||
algorithm: this._keyBackupInfo.algorithm,
|
||||
@@ -214,7 +214,7 @@ export class EncryptionSetupOperation {
|
||||
);
|
||||
} else {
|
||||
// add new key backup
|
||||
await baseApis._http.authedRequest(
|
||||
await baseApis.http.authedRequest(
|
||||
undefined, "POST", "/room_keys/version",
|
||||
undefined, this._keyBackupInfo,
|
||||
{ prefix: PREFIX_UNSTABLE },
|
||||
|
||||
@@ -444,7 +444,7 @@ export class SecretStorage extends EventEmitter {
|
||||
&& this._incomingRequests[deviceId][content.request_id]) {
|
||||
logger.info("received request cancellation for secret (" + sender
|
||||
+ ", " + deviceId + ", " + content.request_id + ")");
|
||||
this.baseApis.emit("crypto.secrets.requestCancelled", {
|
||||
this._baseApis.emit("crypto.secrets.requestCancelled", {
|
||||
user_id: sender,
|
||||
device_id: deviceId,
|
||||
request_id: content.request_id,
|
||||
@@ -480,11 +480,11 @@ export class SecretStorage extends EventEmitter {
|
||||
};
|
||||
const encryptedContent = {
|
||||
algorithm: olmlib.OLM_ALGORITHM,
|
||||
sender_key: this._baseApis._crypto._olmDevice.deviceCurve25519Key,
|
||||
sender_key: this._baseApis.crypto._olmDevice.deviceCurve25519Key,
|
||||
ciphertext: {},
|
||||
};
|
||||
await olmlib.ensureOlmSessionsForDevices(
|
||||
this._baseApis._crypto._olmDevice,
|
||||
this._baseApis.crypto._olmDevice,
|
||||
this._baseApis,
|
||||
{
|
||||
[sender]: [
|
||||
@@ -496,7 +496,7 @@ export class SecretStorage extends EventEmitter {
|
||||
encryptedContent.ciphertext,
|
||||
this._baseApis.getUserId(),
|
||||
this._baseApis.deviceId,
|
||||
this._baseApis._crypto._olmDevice,
|
||||
this._baseApis.crypto._olmDevice,
|
||||
sender,
|
||||
this._baseApis.getStoredDevice(sender, deviceId),
|
||||
payload,
|
||||
@@ -527,7 +527,7 @@ export class SecretStorage extends EventEmitter {
|
||||
if (requestControl) {
|
||||
// make sure that the device that sent it is one of the devices that
|
||||
// we requested from
|
||||
const deviceInfo = this._baseApis._crypto._deviceList.getDeviceByIdentityKey(
|
||||
const deviceInfo = this._baseApis.crypto._deviceList.getDeviceByIdentityKey(
|
||||
olmlib.OLM_ALGORITHM,
|
||||
event.getSenderKey(),
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ export const DECRYPTION_CLASSES = {};
|
||||
* @param {string} params.deviceId The identifier for this device.
|
||||
* @param {module:crypto} params.crypto crypto core
|
||||
* @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper
|
||||
* @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
|
||||
* @param {MatrixClient} baseApis base matrix api interface
|
||||
* @param {string} params.roomId The ID of the room we will be sending to
|
||||
* @param {object} params.config The body of the m.room.encryption event
|
||||
*/
|
||||
@@ -102,7 +102,7 @@ export class EncryptionAlgorithm {
|
||||
* @param {string} params.userId The UserID for the local user
|
||||
* @param {module:crypto} params.crypto crypto core
|
||||
* @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper
|
||||
* @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
|
||||
* @param {MatrixClient} baseApis base matrix api interface
|
||||
* @param {string=} params.roomId The ID of the room we will be receiving
|
||||
* from. Null for to-device events.
|
||||
*/
|
||||
|
||||
@@ -413,9 +413,8 @@ MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) {
|
||||
);
|
||||
|
||||
// don't wait for it to complete
|
||||
this._crypto.backupGroupSession(
|
||||
this._roomId, this._olmDevice.deviceCurve25519Key, [],
|
||||
sessionId, key.key,
|
||||
this._crypto._backupManager.backupGroupSession(
|
||||
this._olmDevice.deviceCurve25519Key, sessionId,
|
||||
);
|
||||
|
||||
return new OutboundSessionInfo(sessionId, sharedHistory);
|
||||
@@ -1425,11 +1424,7 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
|
||||
});
|
||||
}).then(() => {
|
||||
// don't wait for the keys to be backed up for the server
|
||||
this._crypto.backupGroupSession(
|
||||
content.room_id, senderKey, forwardingKeyChain,
|
||||
content.session_id, content.session_key, keysClaimed,
|
||||
exportFormat,
|
||||
);
|
||||
this._crypto._backupManager.backupGroupSession(senderKey, content.session_id);
|
||||
}).catch((e) => {
|
||||
logger.error(`Error handling m.room_key_event: ${e}`);
|
||||
});
|
||||
@@ -1645,14 +1640,8 @@ MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) {
|
||||
).then(() => {
|
||||
if (opts.source !== "backup") {
|
||||
// don't wait for it to complete
|
||||
this._crypto.backupGroupSession(
|
||||
session.room_id,
|
||||
session.sender_key,
|
||||
session.forwarding_curve25519_key_chain,
|
||||
session.session_id,
|
||||
session.session_key,
|
||||
session.sender_claimed_keys,
|
||||
true,
|
||||
this._crypto._backupManager.backupGroupSession(
|
||||
session.sender_key, session.session_id,
|
||||
).catch((e) => {
|
||||
// This throws if the upload failed, but this is fine
|
||||
// since it will have written it to the db and will retry.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
import { IKeyBackupVersion } from "./keybackup";
|
||||
import { ISecretStorageKeyInfo } from "../matrix";
|
||||
|
||||
// TODO: Merge this with crypto.js once converted
|
||||
|
||||
export enum CrossSigningKey {
|
||||
Master = "master",
|
||||
SelfSigning = "self_signing",
|
||||
UserSigning = "user_signing",
|
||||
}
|
||||
|
||||
export interface IEncryptedEventInfo {
|
||||
/**
|
||||
* whether the event is encrypted (if not encrypted, some of the other properties may not be set)
|
||||
*/
|
||||
encrypted: boolean;
|
||||
|
||||
/**
|
||||
* the sender's key
|
||||
*/
|
||||
senderKey: string;
|
||||
|
||||
/**
|
||||
* the algorithm used to encrypt the event
|
||||
*/
|
||||
algorithm: string;
|
||||
|
||||
/**
|
||||
* whether we can be sure that the owner of the senderKey sent the event
|
||||
*/
|
||||
authenticated: boolean;
|
||||
|
||||
/**
|
||||
* the sender's device information, if available
|
||||
*/
|
||||
sender?: DeviceInfo;
|
||||
|
||||
/**
|
||||
* if the event's ed25519 and curve25519 keys don't match (only meaningful if `sender` is set)
|
||||
*/
|
||||
mismatchedSender: boolean;
|
||||
}
|
||||
|
||||
export interface IRecoveryKey {
|
||||
keyInfo: {
|
||||
pubkey: Uint8Array;
|
||||
passphrase?: {
|
||||
algorithm: string;
|
||||
iterations: number;
|
||||
salt: string;
|
||||
};
|
||||
};
|
||||
privateKey: Uint8Array;
|
||||
encodedPrivateKey: string;
|
||||
}
|
||||
|
||||
export interface ICreateSecretStorageOpts {
|
||||
/**
|
||||
* Function called to await a secret storage key creation flow.
|
||||
* Returns:
|
||||
* {Promise<Object>} Object with public key metadata, encoded private
|
||||
* recovery key which should be disposed of after displaying to the user,
|
||||
* and raw private key to avoid round tripping if needed.
|
||||
*/
|
||||
createSecretStorageKey?: () => Promise<IRecoveryKey>;
|
||||
|
||||
/**
|
||||
* The current key backup object. If passed,
|
||||
* the passphrase and recovery key from this backup will be used.
|
||||
*/
|
||||
keyBackupInfo?: IKeyBackupVersion;
|
||||
|
||||
/**
|
||||
* If true, a new key backup version will be
|
||||
* created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
|
||||
* is supplied.
|
||||
*/
|
||||
setupNewKeyBackup?: boolean;
|
||||
|
||||
/**
|
||||
* Reset even if keys already exist.
|
||||
*/
|
||||
setupNewSecretStorage?: boolean;
|
||||
|
||||
/**
|
||||
* Function called to get the user's
|
||||
* current key backup passphrase. Should return a promise that resolves with a Uint8Array
|
||||
* containing the key, or rejects if the key cannot be obtained.
|
||||
*/
|
||||
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface ISecretStorageKey {
|
||||
keyId: string;
|
||||
keyInfo: ISecretStorageKeyInfo;
|
||||
}
|
||||
|
||||
export interface IAddSecretStorageKeyOpts {
|
||||
// depends on algorithm
|
||||
// TODO: Types
|
||||
}
|
||||
|
||||
export interface IImportOpts {
|
||||
stage: string; // TODO: Enum
|
||||
successes: number;
|
||||
failures: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface IImportRoomKeysOpts {
|
||||
progressCallback: (stage: IImportOpts) => void;
|
||||
untrusted?: boolean;
|
||||
source?: string; // TODO: Enum
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @module crypto/backup
|
||||
*
|
||||
* Classes for dealing with key backup.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "../client";
|
||||
import { logger } from "../logger";
|
||||
import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib";
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
import { DeviceTrustLevel } from './CrossSigning';
|
||||
import { keyFromPassphrase } from './key_passphrase';
|
||||
import { sleep } from "../utils";
|
||||
import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store';
|
||||
import { encodeRecoveryKey } from './recoverykey';
|
||||
|
||||
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
|
||||
|
||||
type AuthData = Record<string, any>;
|
||||
|
||||
type BackupInfo = {
|
||||
algorithm: string,
|
||||
auth_data: AuthData, // eslint-disable-line camelcase
|
||||
[properties: string]: any,
|
||||
};
|
||||
|
||||
type SigInfo = {
|
||||
deviceId: string,
|
||||
valid?: boolean | null, // true: valid, false: invalid, null: cannot attempt validation
|
||||
device?: DeviceInfo | null,
|
||||
crossSigningId?: boolean,
|
||||
deviceTrust?: DeviceTrustLevel,
|
||||
};
|
||||
|
||||
type TrustInfo = {
|
||||
usable: boolean, // is the backup trusted, true iff there is a sig that is valid & from a trusted device
|
||||
sigs: SigInfo[],
|
||||
};
|
||||
|
||||
/** A function used to get the secret key for a backup.
|
||||
*/
|
||||
type GetKey = () => Promise<Uint8Array>;
|
||||
|
||||
interface BackupAlgorithmClass {
|
||||
algorithmName: string;
|
||||
// initialize from an existing backup
|
||||
init(authData: AuthData, getKey: GetKey): Promise<BackupAlgorithm>;
|
||||
|
||||
// prepare a brand new backup
|
||||
prepare(
|
||||
key: string | Uint8Array | null,
|
||||
): Promise<[Uint8Array, AuthData]>;
|
||||
}
|
||||
|
||||
interface BackupAlgorithm {
|
||||
encryptSession(data: Record<string, any>): Promise<any>;
|
||||
decryptSessions(ciphertexts: Record<string, any>): Promise<Record<string, any>[]>;
|
||||
authData: AuthData;
|
||||
keyMatches(key: Uint8Array): Promise<boolean>;
|
||||
free(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the key backup.
|
||||
*/
|
||||
export class BackupManager {
|
||||
private algorithm: BackupAlgorithm | undefined;
|
||||
private backupInfo: BackupInfo | undefined; // The info dict from /room_keys/version
|
||||
public checkedForBackup: boolean; // Have we checked the server for a backup we can use?
|
||||
private sendingBackups: boolean; // Are we currently sending backups?
|
||||
constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) {
|
||||
this.checkedForBackup = false;
|
||||
this.sendingBackups = false;
|
||||
}
|
||||
|
||||
public get version(): string | undefined {
|
||||
return this.backupInfo && this.backupInfo.version;
|
||||
}
|
||||
|
||||
public static async makeAlgorithm(info: BackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
|
||||
const Algorithm = algorithmsByName[info.algorithm];
|
||||
if (!Algorithm) {
|
||||
throw new Error("Unknown backup algorithm");
|
||||
}
|
||||
return await Algorithm.init(info.auth_data, getKey);
|
||||
}
|
||||
|
||||
public async enableKeyBackup(info: BackupInfo): Promise<void> {
|
||||
this.backupInfo = info;
|
||||
if (this.algorithm) {
|
||||
this.algorithm.free();
|
||||
}
|
||||
|
||||
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
|
||||
|
||||
this.baseApis.emit('crypto.keyBackupStatus', true);
|
||||
|
||||
// There may be keys left over from a partially completed backup, so
|
||||
// schedule a send to check.
|
||||
this.scheduleKeyBackupSend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable backing up of keys.
|
||||
*/
|
||||
public disableKeyBackup(): void {
|
||||
if (this.algorithm) {
|
||||
this.algorithm.free();
|
||||
}
|
||||
this.algorithm = undefined;
|
||||
|
||||
this.backupInfo = undefined;
|
||||
|
||||
this.baseApis.emit('crypto.keyBackupStatus', false);
|
||||
}
|
||||
|
||||
public getKeyBackupEnabled(): boolean | null {
|
||||
if (!this.checkedForBackup) {
|
||||
return null;
|
||||
}
|
||||
return Boolean(this.algorithm);
|
||||
}
|
||||
|
||||
public async prepareKeyBackupVersion(
|
||||
key?: string | Uint8Array | null,
|
||||
algorithm?: string | undefined,
|
||||
): Promise<BackupInfo> {
|
||||
const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm;
|
||||
if (!Algorithm) {
|
||||
throw new Error("Unknown backup algorithm");
|
||||
}
|
||||
|
||||
const [privateKey, authData] = await Algorithm.prepare(key);
|
||||
const recoveryKey = encodeRecoveryKey(privateKey);
|
||||
return {
|
||||
algorithm: Algorithm.algorithmName,
|
||||
auth_data: authData,
|
||||
recovery_key: recoveryKey,
|
||||
privateKey,
|
||||
};
|
||||
}
|
||||
|
||||
public async createKeyBackupVersion(info: BackupInfo): Promise<void> {
|
||||
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the server for an active key backup and
|
||||
* if one is present and has a valid signature from
|
||||
* one of the user's verified devices, start backing up
|
||||
* to it.
|
||||
*/
|
||||
public async checkAndStart(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> {
|
||||
logger.log("Checking key backup status...");
|
||||
if (this.baseApis.isGuest()) {
|
||||
logger.log("Skipping key backup check since user is guest");
|
||||
this.checkedForBackup = true;
|
||||
return null;
|
||||
}
|
||||
let backupInfo: BackupInfo;
|
||||
try {
|
||||
backupInfo = await this.baseApis.getKeyBackupVersion();
|
||||
} catch (e) {
|
||||
logger.log("Error checking for active key backup", e);
|
||||
if (e.httpStatus === 404) {
|
||||
// 404 is returned when the key backup does not exist, so that
|
||||
// counts as successfully checking.
|
||||
this.checkedForBackup = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
this.checkedForBackup = true;
|
||||
|
||||
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
|
||||
|
||||
if (trustInfo.usable && !this.backupInfo) {
|
||||
logger.log(
|
||||
"Found usable key backup v" + backupInfo.version +
|
||||
": enabling key backups",
|
||||
);
|
||||
await this.enableKeyBackup(backupInfo);
|
||||
} else if (!trustInfo.usable && this.backupInfo) {
|
||||
logger.log("No usable key backup: disabling key backup");
|
||||
this.disableKeyBackup();
|
||||
} else if (!trustInfo.usable && !this.backupInfo) {
|
||||
logger.log("No usable key backup: not enabling key backup");
|
||||
} else if (trustInfo.usable && this.backupInfo) {
|
||||
// may not be the same version: if not, we should switch
|
||||
if (backupInfo.version !== this.backupInfo.version) {
|
||||
logger.log(
|
||||
"On backup version " + this.backupInfo.version + " but found " +
|
||||
"version " + backupInfo.version + ": switching.",
|
||||
);
|
||||
this.disableKeyBackup();
|
||||
await this.enableKeyBackup(backupInfo);
|
||||
// We're now using a new backup, so schedule all the keys we have to be
|
||||
// uploaded to the new backup. This is a bit of a workaround to upload
|
||||
// keys to a new backup in *most* cases, but it won't cover all cases
|
||||
// because we don't remember what backup version we uploaded keys to:
|
||||
// see https://github.com/vector-im/element-web/issues/14833
|
||||
await this.scheduleAllGroupSessionsForBackup();
|
||||
} else {
|
||||
logger.log("Backup version " + backupInfo.version + " still current");
|
||||
}
|
||||
}
|
||||
|
||||
return { backupInfo, trustInfo };
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a re-check of the key backup and enables/disables it
|
||||
* as appropriate.
|
||||
*
|
||||
* @return {Object} Object with backup info (as returned by
|
||||
* getKeyBackupVersion) in backupInfo and
|
||||
* trust information (as returned by isKeyBackupTrusted)
|
||||
* in trustInfo.
|
||||
*/
|
||||
public async checkKeyBackup(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> {
|
||||
this.checkedForBackup = false;
|
||||
return this.checkAndStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given backup info is trusted.
|
||||
*
|
||||
* @param {object} backupInfo key backup info dict from /room_keys/version
|
||||
* @return {object} {
|
||||
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
|
||||
* sigs: [
|
||||
* valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation
|
||||
* deviceId: [string],
|
||||
* device: [DeviceInfo || null],
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
public async isKeyBackupTrusted(backupInfo: BackupInfo): Promise<TrustInfo> {
|
||||
const ret = {
|
||||
usable: false,
|
||||
trusted_locally: false,
|
||||
sigs: [],
|
||||
};
|
||||
|
||||
if (
|
||||
!backupInfo ||
|
||||
!backupInfo.algorithm ||
|
||||
!backupInfo.auth_data ||
|
||||
!backupInfo.auth_data.public_key ||
|
||||
!backupInfo.auth_data.signatures
|
||||
) {
|
||||
logger.info("Key backup is absent or missing required data");
|
||||
return ret;
|
||||
}
|
||||
|
||||
const trustedPubkey = this.baseApis.crypto._sessionStore.getLocalTrustedBackupPubKey();
|
||||
|
||||
if (backupInfo.auth_data.public_key === trustedPubkey) {
|
||||
logger.info("Backup public key " + trustedPubkey + " is trusted locally");
|
||||
ret.trusted_locally = true;
|
||||
}
|
||||
|
||||
const mySigs = backupInfo.auth_data.signatures[this.baseApis.getUserId()] || [];
|
||||
|
||||
for (const keyId of Object.keys(mySigs)) {
|
||||
const keyIdParts = keyId.split(':');
|
||||
if (keyIdParts[0] !== 'ed25519') {
|
||||
logger.log("Ignoring unknown signature type: " + keyIdParts[0]);
|
||||
continue;
|
||||
}
|
||||
// Could be a cross-signing master key, but just say this is the device
|
||||
// ID for backwards compat
|
||||
const sigInfo: SigInfo = { deviceId: keyIdParts[1] };
|
||||
|
||||
// first check to see if it's from our cross-signing key
|
||||
const crossSigningId = this.baseApis.crypto._crossSigningInfo.getId();
|
||||
if (crossSigningId === sigInfo.deviceId) {
|
||||
sigInfo.crossSigningId = true;
|
||||
try {
|
||||
await verifySignature(
|
||||
this.baseApis.crypto._olmDevice,
|
||||
backupInfo.auth_data,
|
||||
this.baseApis.getUserId(),
|
||||
sigInfo.deviceId,
|
||||
crossSigningId,
|
||||
);
|
||||
sigInfo.valid = true;
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
"Bad signature from cross signing key " + crossSigningId, e,
|
||||
);
|
||||
sigInfo.valid = false;
|
||||
}
|
||||
ret.sigs.push(sigInfo);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Now look for a sig from a device
|
||||
// At some point this can probably go away and we'll just support
|
||||
// it being signed by the cross-signing master key
|
||||
const device = this.baseApis.crypto._deviceList.getStoredDevice(
|
||||
this.baseApis.getUserId(), sigInfo.deviceId,
|
||||
);
|
||||
if (device) {
|
||||
sigInfo.device = device;
|
||||
sigInfo.deviceTrust = await this.baseApis.checkDeviceTrust(
|
||||
this.baseApis.getUserId(), sigInfo.deviceId,
|
||||
);
|
||||
try {
|
||||
await verifySignature(
|
||||
this.baseApis.crypto._olmDevice,
|
||||
backupInfo.auth_data,
|
||||
this.baseApis.getUserId(),
|
||||
device.deviceId,
|
||||
device.getFingerprint(),
|
||||
);
|
||||
sigInfo.valid = true;
|
||||
} catch (e) {
|
||||
logger.info(
|
||||
"Bad signature from key ID " + keyId + " userID " + this.baseApis.getUserId() +
|
||||
" device ID " + device.deviceId + " fingerprint: " +
|
||||
device.getFingerprint(), backupInfo.auth_data, e,
|
||||
);
|
||||
sigInfo.valid = false;
|
||||
}
|
||||
} else {
|
||||
sigInfo.valid = null; // Can't determine validity because we don't have the signing device
|
||||
logger.info("Ignoring signature from unknown key " + keyId);
|
||||
}
|
||||
ret.sigs.push(sigInfo);
|
||||
}
|
||||
|
||||
ret.usable = ret.sigs.some((s) => {
|
||||
return (
|
||||
s.valid && (
|
||||
(s.device && s.deviceTrust.isVerified()) ||
|
||||
(s.crossSigningId)
|
||||
)
|
||||
);
|
||||
});
|
||||
ret.usable = ret.usable || ret.trusted_locally;
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules sending all keys waiting to be sent to the backup, if not already
|
||||
* scheduled. Retries if necessary.
|
||||
*
|
||||
* @param maxDelay Maximum delay to wait in ms. 0 means no delay.
|
||||
*/
|
||||
public async scheduleKeyBackupSend(maxDelay = 10000): Promise<void> {
|
||||
if (this.sendingBackups) return;
|
||||
|
||||
this.sendingBackups = true;
|
||||
|
||||
try {
|
||||
// wait between 0 and `maxDelay` seconds, to avoid backup
|
||||
// requests from different clients hitting the server all at
|
||||
// the same time when a new key is sent
|
||||
const delay = Math.random() * maxDelay;
|
||||
await sleep(delay, undefined);
|
||||
let numFailures = 0; // number of consecutive failures
|
||||
for (;;) {
|
||||
if (!this.algorithm) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const numBackedUp =
|
||||
await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
|
||||
if (numBackedUp === 0) {
|
||||
// no sessions left needing backup: we're done
|
||||
return;
|
||||
}
|
||||
numFailures = 0;
|
||||
} catch (err) {
|
||||
numFailures++;
|
||||
logger.log("Key backup request failed", err);
|
||||
if (err.data) {
|
||||
if (
|
||||
err.data.errcode == 'M_NOT_FOUND' ||
|
||||
err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION'
|
||||
) {
|
||||
// Re-check key backup status on error, so we can be
|
||||
// sure to present the current situation when asked.
|
||||
await this.checkKeyBackup();
|
||||
// Backup version has changed or this backup version
|
||||
// has been deleted
|
||||
this.baseApis.crypto.emit("crypto.keyBackupFailed", err.data.errcode);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (numFailures) {
|
||||
// exponential backoff if we have failures
|
||||
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)), undefined);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.sendingBackups = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take some e2e keys waiting to be backed up and send them
|
||||
* to the backup.
|
||||
*
|
||||
* @param {integer} limit Maximum number of keys to back up
|
||||
* @returns {integer} Number of sessions backed up
|
||||
*/
|
||||
private async backupPendingKeys(limit: number): Promise<number> {
|
||||
const sessions = await this.baseApis.crypto._cryptoStore.getSessionsNeedingBackup(limit);
|
||||
if (!sessions.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
|
||||
this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining);
|
||||
|
||||
const data = {};
|
||||
for (const session of sessions) {
|
||||
const roomId = session.sessionData.room_id;
|
||||
if (data[roomId] === undefined) {
|
||||
data[roomId] = { sessions: {} };
|
||||
}
|
||||
|
||||
const sessionData = await this.baseApis.crypto._olmDevice.exportInboundGroupSession(
|
||||
session.senderKey, session.sessionId, session.sessionData,
|
||||
);
|
||||
sessionData.algorithm = MEGOLM_ALGORITHM;
|
||||
|
||||
const forwardedCount =
|
||||
(sessionData.forwarding_curve25519_key_chain || []).length;
|
||||
|
||||
const userId = this.baseApis.crypto._deviceList.getUserByIdentityKey(
|
||||
MEGOLM_ALGORITHM, session.senderKey,
|
||||
);
|
||||
const device = this.baseApis.crypto._deviceList.getDeviceByIdentityKey(
|
||||
MEGOLM_ALGORITHM, session.senderKey,
|
||||
);
|
||||
const verified = this.baseApis.crypto._checkDeviceInfoTrust(userId, device).isVerified();
|
||||
|
||||
data[roomId]['sessions'][session.sessionId] = {
|
||||
first_message_index: sessionData.first_known_index,
|
||||
forwarded_count: forwardedCount,
|
||||
is_verified: verified,
|
||||
session_data: await this.algorithm.encryptSession(sessionData),
|
||||
};
|
||||
}
|
||||
|
||||
await this.baseApis.sendKeyBackup(
|
||||
undefined, undefined, this.backupInfo.version,
|
||||
{ rooms: data },
|
||||
);
|
||||
|
||||
await this.baseApis.crypto._cryptoStore.unmarkSessionsNeedingBackup(sessions);
|
||||
remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
|
||||
this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining);
|
||||
|
||||
return sessions.length;
|
||||
}
|
||||
|
||||
public async backupGroupSession(
|
||||
senderKey: string, sessionId: string,
|
||||
): Promise<void> {
|
||||
await this.baseApis.crypto._cryptoStore.markSessionsNeedingBackup([{
|
||||
senderKey: senderKey,
|
||||
sessionId: sessionId,
|
||||
}]);
|
||||
|
||||
if (this.backupInfo) {
|
||||
// don't wait for this to complete: it will delay so
|
||||
// happens in the background
|
||||
this.scheduleKeyBackupSend();
|
||||
}
|
||||
// if this.backupInfo is not set, then the keys will be backed up when
|
||||
// this.enableKeyBackup is called
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all group sessions as needing to be backed up and schedules them to
|
||||
* upload in the background as soon as possible.
|
||||
*/
|
||||
public async scheduleAllGroupSessionsForBackup(): Promise<void> {
|
||||
await this.flagAllGroupSessionsForBackup();
|
||||
|
||||
// Schedule keys to upload in the background as soon as possible.
|
||||
this.scheduleKeyBackupSend(0 /* maxDelay */);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all group sessions as needing to be backed up without scheduling
|
||||
* them to upload in the background.
|
||||
* @returns {Promise<int>} Resolves to the number of sessions now requiring a backup
|
||||
* (which will be equal to the number of sessions in the store).
|
||||
*/
|
||||
public async flagAllGroupSessionsForBackup(): Promise<number> {
|
||||
await this.baseApis.crypto._cryptoStore.doTxn(
|
||||
'readwrite',
|
||||
[
|
||||
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
|
||||
IndexedDBCryptoStore.STORE_BACKUP,
|
||||
],
|
||||
(txn) => {
|
||||
this.baseApis.crypto._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
|
||||
if (session !== null) {
|
||||
this.baseApis.crypto._cryptoStore.markSessionsNeedingBackup([session], txn);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
|
||||
this.baseApis.emit("crypto.keyBackupSessionsRemaining", remaining);
|
||||
return remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of end to end session keys that are waiting to be backed up
|
||||
* @returns {Promise<int>} Resolves to the number of sessions requiring backup
|
||||
*/
|
||||
public countSessionsNeedingBackup(): Promise<number> {
|
||||
return this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
|
||||
}
|
||||
}
|
||||
|
||||
export class Curve25519 implements BackupAlgorithm {
|
||||
public static algorithmName = "m.megolm_backup.v1.curve25519-aes-sha2";
|
||||
|
||||
constructor(
|
||||
public authData: AuthData,
|
||||
private publicKey: any, // FIXME: PkEncryption
|
||||
private getKey: () => Promise<Uint8Array>,
|
||||
) {}
|
||||
|
||||
public static async init(
|
||||
authData: AuthData,
|
||||
getKey: () => Promise<Uint8Array>,
|
||||
): Promise<Curve25519> {
|
||||
if (!authData || !authData.public_key) {
|
||||
throw new Error("auth_data missing required information");
|
||||
}
|
||||
const publicKey = new global.Olm.PkEncryption();
|
||||
publicKey.set_recipient_key(authData.public_key);
|
||||
return new Curve25519(authData, publicKey, getKey);
|
||||
}
|
||||
|
||||
public static async prepare(
|
||||
key: string | Uint8Array | null,
|
||||
): Promise<[Uint8Array, AuthData]> {
|
||||
const decryption = new global.Olm.PkDecryption();
|
||||
try {
|
||||
const authData: AuthData = {};
|
||||
if (!key) {
|
||||
authData.public_key = decryption.generate_key();
|
||||
} else if (key instanceof Uint8Array) {
|
||||
authData.public_key = decryption.init_with_private_key(key);
|
||||
} else {
|
||||
const derivation = await keyFromPassphrase(key);
|
||||
authData.private_key_salt = derivation.salt;
|
||||
authData.private_key_iterations = derivation.iterations;
|
||||
authData.public_key = decryption.init_with_private_key(derivation.key);
|
||||
}
|
||||
const publicKey = new global.Olm.PkEncryption();
|
||||
publicKey.set_recipient_key(authData.public_key);
|
||||
|
||||
return [
|
||||
decryption.get_private_key(),
|
||||
authData,
|
||||
];
|
||||
} finally {
|
||||
decryption.free();
|
||||
}
|
||||
}
|
||||
|
||||
public async encryptSession(data: Record<string, any>): Promise<any> {
|
||||
const plainText: Record<string, any> = Object.assign({}, data);
|
||||
delete plainText.session_id;
|
||||
delete plainText.room_id;
|
||||
delete plainText.first_known_index;
|
||||
return this.publicKey.encrypt(JSON.stringify(plainText));
|
||||
}
|
||||
|
||||
public async decryptSessions(sessions: Record<string, Record<string, any>>): Promise<Record<string, any>[]> {
|
||||
const privKey = await this.getKey();
|
||||
const decryption = new global.Olm.PkDecryption();
|
||||
try {
|
||||
const backupPubKey = decryption.init_with_private_key(privKey);
|
||||
|
||||
if (backupPubKey !== this.authData.public_key) {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw { errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY };
|
||||
}
|
||||
|
||||
const keys = [];
|
||||
|
||||
for (const [sessionId, sessionData] of Object.entries(sessions)) {
|
||||
try {
|
||||
const decrypted = JSON.parse(decryption.decrypt(
|
||||
sessionData.session_data.ephemeral,
|
||||
sessionData.session_data.mac,
|
||||
sessionData.session_data.ciphertext,
|
||||
));
|
||||
decrypted.session_id = sessionId;
|
||||
keys.push(decrypted);
|
||||
} catch (e) {
|
||||
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
} finally {
|
||||
decryption.free();
|
||||
}
|
||||
}
|
||||
|
||||
public async keyMatches(key: Uint8Array): Promise<boolean> {
|
||||
const decryption = new global.Olm.PkDecryption();
|
||||
let pubKey;
|
||||
try {
|
||||
pubKey = decryption.init_with_private_key(key);
|
||||
} finally {
|
||||
decryption.free();
|
||||
}
|
||||
|
||||
return pubKey === this.authData.public_key;
|
||||
}
|
||||
|
||||
public free(): void {
|
||||
this.publicKey.free();
|
||||
}
|
||||
}
|
||||
|
||||
export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
|
||||
[Curve25519.algorithmName]: Curve25519,
|
||||
};
|
||||
|
||||
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2020-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,10 +19,23 @@ import { IndexedDBCryptoStore } from '../crypto/store/indexeddb-crypto-store';
|
||||
import { decryptAES, encryptAES } from './aes';
|
||||
import anotherjson from "another-json";
|
||||
import { logger } from '../logger';
|
||||
import { ISecretStorageKeyInfo } from "../matrix";
|
||||
|
||||
// FIXME: these types should eventually go in a different file
|
||||
type Signatures = Record<string, Record<string, string>>;
|
||||
|
||||
export interface IDehydratedDevice {
|
||||
device_id: string; // eslint-disable-line camelcase
|
||||
device_data: ISecretStorageKeyInfo & { // eslint-disable-line camelcase
|
||||
algorithm: string;
|
||||
account: string; // pickle
|
||||
};
|
||||
}
|
||||
|
||||
export interface IDehydratedDeviceKeyInfo {
|
||||
passphrase?: string;
|
||||
}
|
||||
|
||||
interface DeviceKeys {
|
||||
algorithms: Array<string>;
|
||||
device_id: string; // eslint-disable-line camelcase
|
||||
@@ -192,7 +205,7 @@ export class DehydrationManager {
|
||||
}
|
||||
|
||||
logger.log("Uploading account to server");
|
||||
const dehydrateResult = await this.crypto._baseApis._http.authedRequest(
|
||||
const dehydrateResult = await this.crypto._baseApis.http.authedRequest(
|
||||
undefined,
|
||||
"PUT",
|
||||
"/dehydrated_device",
|
||||
@@ -255,7 +268,7 @@ export class DehydrationManager {
|
||||
}
|
||||
|
||||
logger.log("Uploading keys to server");
|
||||
await this.crypto._baseApis._http.authedRequest(
|
||||
await this.crypto._baseApis.http.authedRequest(
|
||||
undefined,
|
||||
"POST",
|
||||
"/keys/upload/" + encodeURI(deviceId),
|
||||
|
||||
+54
-403
@@ -2,7 +2,7 @@
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018-2019 New Vector Ltd
|
||||
Copyright 2019-2020 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.
|
||||
@@ -26,7 +26,6 @@ import { EventEmitter } from 'events';
|
||||
import { ReEmitter } from '../ReEmitter';
|
||||
import { logger } from '../logger';
|
||||
import * as utils from "../utils";
|
||||
import { sleep } from "../utils";
|
||||
import { OlmDevice } from "./OlmDevice";
|
||||
import * as olmlib from "./olmlib";
|
||||
import { DeviceList } from "./DeviceList";
|
||||
@@ -58,6 +57,7 @@ import { KeySignatureUploadError } from "../errors";
|
||||
import { decryptAES, encryptAES } from './aes';
|
||||
import { DehydrationManager } from './dehydration';
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { BackupManager } from "./backup";
|
||||
|
||||
const DeviceVerification = DeviceInfo.DeviceVerification;
|
||||
|
||||
@@ -85,7 +85,6 @@ export function isCryptoAvailable() {
|
||||
}
|
||||
|
||||
const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
|
||||
|
||||
/**
|
||||
* Cryptography bits
|
||||
@@ -97,7 +96,7 @@ const KEY_BACKUP_KEYS_PER_REQUEST = 200;
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
|
||||
* @param {MatrixClient} baseApis base matrix api interface
|
||||
*
|
||||
* @param {module:store/session/webstorage~WebStorageSessionStore} sessionStore
|
||||
* Store to be used for end-to-end crypto session data
|
||||
@@ -154,13 +153,36 @@ export function Crypto(baseApis, sessionStore, userId, deviceId,
|
||||
} else {
|
||||
this._verificationMethods = defaultVerificationMethods;
|
||||
}
|
||||
// track whether this device's megolm keys are being backed up incrementally
|
||||
// to the server or not.
|
||||
// XXX: this should probably have a single source of truth from OlmAccount
|
||||
this.backupInfo = null; // The info dict from /room_keys/version
|
||||
this.backupKey = null; // The encryption key object
|
||||
this._checkedForBackup = false; // Have we checked the server for a backup we can use?
|
||||
this._sendingBackups = false; // Are we currently sending backups?
|
||||
|
||||
this._backupManager = new BackupManager(baseApis, async (algorithm) => {
|
||||
// try to get key from cache
|
||||
const cachedKey = await this.getSessionBackupPrivateKey();
|
||||
if (cachedKey) {
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
// try to get key from secret storage
|
||||
const storedKey = await this.getSecret("m.megolm_backup.v1");
|
||||
|
||||
if (storedKey) {
|
||||
// ensure that the key is in the right format. If not, fix the key and
|
||||
// store the fixed version
|
||||
const fixedKey = fixBackupKey(storedKey);
|
||||
if (fixedKey) {
|
||||
const [keyId] = await this._crypto.getSecretStorageKey();
|
||||
await this.storeSecret("m.megolm_backup.v1", fixedKey, [keyId]);
|
||||
}
|
||||
|
||||
return olmlib.decodeBase64(fixedKey || storedKey);
|
||||
}
|
||||
|
||||
// try to get key from app
|
||||
if (this._baseApis._cryptoCallbacks && this._baseApis._cryptoCallbacks.getBackupKey) {
|
||||
return await this._baseApis._cryptoCallbacks.getBackupKey(algorithm);
|
||||
}
|
||||
|
||||
throw new Error("Unable to get private key");
|
||||
});
|
||||
|
||||
this._olmDevice = new OlmDevice(cryptoStore);
|
||||
this._deviceList = new DeviceList(
|
||||
@@ -232,7 +254,7 @@ export function Crypto(baseApis, sessionStore, userId, deviceId,
|
||||
// processing the response.
|
||||
this._sendKeyRequestsImmediately = false;
|
||||
|
||||
const cryptoCallbacks = this._baseApis._cryptoCallbacks || {};
|
||||
const cryptoCallbacks = this._baseApis.cryptoCallbacks || {};
|
||||
const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this._olmDevice);
|
||||
|
||||
this._crossSigningInfo = new CrossSigningInfo(
|
||||
@@ -331,7 +353,7 @@ Crypto.prototype.init = async function(opts) {
|
||||
this._deviceList.startTrackingDeviceList(this._userId);
|
||||
|
||||
logger.log("Crypto: checking for key backup...");
|
||||
this._checkAndStartKeyBackup();
|
||||
this._backupManager.checkAndStart();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -458,7 +480,7 @@ Crypto.prototype.isSecretStorageReady = async function() {
|
||||
this._secretStorage,
|
||||
);
|
||||
const sessionBackupInStorage = (
|
||||
!this._baseApis.getKeyBackupEnabled() ||
|
||||
!this._backupManager.getKeyBackupEnabled() ||
|
||||
this._baseApis.isKeyBackupKeyStored()
|
||||
);
|
||||
|
||||
@@ -495,7 +517,7 @@ Crypto.prototype.bootstrapCrossSigning = async function({
|
||||
} = {}) {
|
||||
logger.log("Bootstrapping cross-signing");
|
||||
|
||||
const delegateCryptoCallbacks = this._baseApis._cryptoCallbacks;
|
||||
const delegateCryptoCallbacks = this._baseApis.cryptoCallbacks;
|
||||
const builder = new EncryptionSetupBuilder(
|
||||
this._baseApis.store.accountData,
|
||||
delegateCryptoCallbacks,
|
||||
@@ -522,9 +544,11 @@ Crypto.prototype.bootstrapCrossSigning = async function({
|
||||
builder.addKeySignature(this._userId, this._deviceId, deviceSignature);
|
||||
|
||||
// Sign message key backup with cross-signing master key
|
||||
if (this.backupInfo) {
|
||||
await crossSigningInfo.signObject(this.backupInfo.auth_data, "master");
|
||||
builder.addSessionBackup(this.backupInfo);
|
||||
if (this._backupManager.backupInfo) {
|
||||
await crossSigningInfo.signObject(
|
||||
this._backupManager.backupInfo.auth_data, "master",
|
||||
);
|
||||
builder.addSessionBackup(this._backupManager.backupInfo);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -579,7 +603,7 @@ Crypto.prototype.bootstrapCrossSigning = async function({
|
||||
const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys;
|
||||
if (
|
||||
crossSigningPrivateKeys.size &&
|
||||
!this._baseApis._cryptoCallbacks.saveCrossSigningKeys
|
||||
!this._baseApis.cryptoCallbacks.saveCrossSigningKeys
|
||||
) {
|
||||
const secretStorage = new SecretStorage(
|
||||
builder.accountDataClientAdapter,
|
||||
@@ -646,7 +670,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({
|
||||
getKeyBackupPassphrase,
|
||||
} = {}) {
|
||||
logger.log("Bootstrapping Secure Secret Storage");
|
||||
const delegateCryptoCallbacks = this._baseApis._cryptoCallbacks;
|
||||
const delegateCryptoCallbacks = this._baseApis.cryptoCallbacks;
|
||||
const builder = new EncryptionSetupBuilder(
|
||||
this._baseApis.store.accountData,
|
||||
delegateCryptoCallbacks,
|
||||
@@ -681,7 +705,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({
|
||||
|
||||
const ensureCanCheckPassphrase = async (keyId, keyInfo) => {
|
||||
if (!keyInfo.mac) {
|
||||
const key = await this._baseApis._cryptoCallbacks.getSecretStorageKey(
|
||||
const key = await this._baseApis.cryptoCallbacks.getSecretStorageKey(
|
||||
{ keys: { [keyId]: keyInfo } }, "",
|
||||
);
|
||||
if (key) {
|
||||
@@ -766,6 +790,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({
|
||||
keyBackupInfo.auth_data.private_key_salt &&
|
||||
keyBackupInfo.auth_data.private_key_iterations
|
||||
) {
|
||||
// FIXME: ???
|
||||
opts.passphrase = {
|
||||
algorithm: "m.pbkdf2",
|
||||
iterations: keyBackupInfo.auth_data.private_key_iterations,
|
||||
@@ -801,7 +826,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({
|
||||
// If we have cross-signing private keys cached, store them in secret
|
||||
// storage if they are not there already.
|
||||
if (
|
||||
!this._baseApis._cryptoCallbacks.saveCrossSigningKeys &&
|
||||
!this._baseApis.cryptoCallbacks.saveCrossSigningKeys &&
|
||||
await this.isCrossSigningReady() &&
|
||||
(newKeyId || !await this._crossSigningInfo.isStoredInSecretStorage(secretStorage))
|
||||
) {
|
||||
@@ -1071,7 +1096,7 @@ Crypto.prototype._afterCrossSigningLocalKeyChange = async function() {
|
||||
upload({ shouldEmit: true });
|
||||
|
||||
const shouldUpgradeCb = (
|
||||
this._baseApis._cryptoCallbacks.shouldUpgradeDeviceVerifications
|
||||
this._baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications
|
||||
);
|
||||
if (shouldUpgradeCb) {
|
||||
logger.info("Starting device verification upgrade");
|
||||
@@ -1477,7 +1502,7 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function({
|
||||
}
|
||||
|
||||
// Now we may be able to trust our key backup
|
||||
await this.checkKeyBackup();
|
||||
await this._backupManager.checkKeyBackup();
|
||||
// FIXME: if we previously trusted the backup, should we automatically sign
|
||||
// the backup with the new key (if not already signed)?
|
||||
};
|
||||
@@ -1509,7 +1534,7 @@ Crypto.prototype._storeTrustedSelfKeys = async function(keys) {
|
||||
*/
|
||||
Crypto.prototype._checkDeviceVerifications = async function(userId) {
|
||||
const shouldUpgradeCb = (
|
||||
this._baseApis._cryptoCallbacks.shouldUpgradeDeviceVerifications
|
||||
this._baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications
|
||||
);
|
||||
if (!shouldUpgradeCb) {
|
||||
// Upgrading skipped when callback is not present.
|
||||
@@ -1539,206 +1564,11 @@ Crypto.prototype._checkDeviceVerifications = async function(userId) {
|
||||
logger.info(`Finished device verification upgrade for ${userId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check the server for an active key backup and
|
||||
* if one is present and has a valid signature from
|
||||
* one of the user's verified devices, start backing up
|
||||
* to it.
|
||||
*/
|
||||
Crypto.prototype._checkAndStartKeyBackup = async function() {
|
||||
logger.log("Checking key backup status...");
|
||||
if (this._baseApis.isGuest()) {
|
||||
logger.log("Skipping key backup check since user is guest");
|
||||
this._checkedForBackup = true;
|
||||
return null;
|
||||
}
|
||||
let backupInfo;
|
||||
try {
|
||||
backupInfo = await this._baseApis.getKeyBackupVersion();
|
||||
} catch (e) {
|
||||
logger.log("Error checking for active key backup", e);
|
||||
if (e.httpStatus === 404) {
|
||||
// 404 is returned when the key backup does not exist, so that
|
||||
// counts as successfully checking.
|
||||
this._checkedForBackup = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
this._checkedForBackup = true;
|
||||
|
||||
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
|
||||
|
||||
if (trustInfo.usable && !this.backupInfo) {
|
||||
logger.log(
|
||||
"Found usable key backup v" + backupInfo.version +
|
||||
": enabling key backups",
|
||||
);
|
||||
this._baseApis.enableKeyBackup(backupInfo);
|
||||
} else if (!trustInfo.usable && this.backupInfo) {
|
||||
logger.log("No usable key backup: disabling key backup");
|
||||
this._baseApis.disableKeyBackup();
|
||||
} else if (!trustInfo.usable && !this.backupInfo) {
|
||||
logger.log("No usable key backup: not enabling key backup");
|
||||
} else if (trustInfo.usable && this.backupInfo) {
|
||||
// may not be the same version: if not, we should switch
|
||||
if (backupInfo.version !== this.backupInfo.version) {
|
||||
logger.log(
|
||||
"On backup version " + this.backupInfo.version + " but found " +
|
||||
"version " + backupInfo.version + ": switching.",
|
||||
);
|
||||
this._baseApis.disableKeyBackup();
|
||||
this._baseApis.enableKeyBackup(backupInfo);
|
||||
// We're now using a new backup, so schedule all the keys we have to be
|
||||
// uploaded to the new backup. This is a bit of a workaround to upload
|
||||
// keys to a new backup in *most* cases, but it won't cover all cases
|
||||
// because we don't remember what backup version we uploaded keys to:
|
||||
// see https://github.com/vector-im/element-web/issues/14833
|
||||
await this.scheduleAllGroupSessionsForBackup();
|
||||
} else {
|
||||
logger.log("Backup version " + backupInfo.version + " still current");
|
||||
}
|
||||
}
|
||||
|
||||
return { backupInfo, trustInfo };
|
||||
};
|
||||
|
||||
Crypto.prototype.setTrustedBackupPubKey = async function(trustedPubKey) {
|
||||
// This should be redundant post cross-signing is a thing, so just
|
||||
// plonk it in localStorage for now.
|
||||
this._sessionStore.setLocalTrustedBackupPubKey(trustedPubKey);
|
||||
await this.checkKeyBackup();
|
||||
};
|
||||
|
||||
/**
|
||||
* Forces a re-check of the key backup and enables/disables it
|
||||
* as appropriate.
|
||||
*
|
||||
* @return {Object} Object with backup info (as returned by
|
||||
* getKeyBackupVersion) in backupInfo and
|
||||
* trust information (as returned by isKeyBackupTrusted)
|
||||
* in trustInfo.
|
||||
*/
|
||||
Crypto.prototype.checkKeyBackup = async function() {
|
||||
this._checkedForBackup = false;
|
||||
return this._checkAndStartKeyBackup();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {object} backupInfo key backup info dict from /room_keys/version
|
||||
* @return {object} {
|
||||
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
|
||||
* sigs: [
|
||||
* valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation
|
||||
* deviceId: [string],
|
||||
* device: [DeviceInfo || null],
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
Crypto.prototype.isKeyBackupTrusted = async function(backupInfo) {
|
||||
const ret = {
|
||||
usable: false,
|
||||
trusted_locally: false,
|
||||
sigs: [],
|
||||
};
|
||||
|
||||
if (
|
||||
!backupInfo ||
|
||||
!backupInfo.algorithm ||
|
||||
!backupInfo.auth_data ||
|
||||
!backupInfo.auth_data.public_key ||
|
||||
!backupInfo.auth_data.signatures
|
||||
) {
|
||||
logger.info("Key backup is absent or missing required data");
|
||||
return ret;
|
||||
}
|
||||
|
||||
const trustedPubkey = this._sessionStore.getLocalTrustedBackupPubKey();
|
||||
|
||||
if (backupInfo.auth_data.public_key === trustedPubkey) {
|
||||
logger.info("Backup public key " + trustedPubkey + " is trusted locally");
|
||||
ret.trusted_locally = true;
|
||||
}
|
||||
|
||||
const mySigs = backupInfo.auth_data.signatures[this._userId] || [];
|
||||
|
||||
for (const keyId of Object.keys(mySigs)) {
|
||||
const keyIdParts = keyId.split(':');
|
||||
if (keyIdParts[0] !== 'ed25519') {
|
||||
logger.log("Ignoring unknown signature type: " + keyIdParts[0]);
|
||||
continue;
|
||||
}
|
||||
// Could be a cross-signing master key, but just say this is the device
|
||||
// ID for backwards compat
|
||||
const sigInfo = { deviceId: keyIdParts[1] };
|
||||
|
||||
// first check to see if it's from our cross-signing key
|
||||
const crossSigningId = this._crossSigningInfo.getId();
|
||||
if (crossSigningId === sigInfo.deviceId) {
|
||||
sigInfo.crossSigningId = true;
|
||||
try {
|
||||
await olmlib.verifySignature(
|
||||
this._olmDevice,
|
||||
backupInfo.auth_data,
|
||||
this._userId,
|
||||
sigInfo.deviceId,
|
||||
crossSigningId,
|
||||
);
|
||||
sigInfo.valid = true;
|
||||
} catch (e) {
|
||||
logger.warning(
|
||||
"Bad signature from cross signing key " + crossSigningId, e,
|
||||
);
|
||||
sigInfo.valid = false;
|
||||
}
|
||||
ret.sigs.push(sigInfo);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Now look for a sig from a device
|
||||
// At some point this can probably go away and we'll just support
|
||||
// it being signed by the cross-signing master key
|
||||
const device = this._deviceList.getStoredDevice(
|
||||
this._userId, sigInfo.deviceId,
|
||||
);
|
||||
if (device) {
|
||||
sigInfo.device = device;
|
||||
sigInfo.deviceTrust = await this.checkDeviceTrust(
|
||||
this._userId, sigInfo.deviceId,
|
||||
);
|
||||
try {
|
||||
await olmlib.verifySignature(
|
||||
this._olmDevice,
|
||||
backupInfo.auth_data,
|
||||
this._userId,
|
||||
device.deviceId,
|
||||
device.getFingerprint(),
|
||||
);
|
||||
sigInfo.valid = true;
|
||||
} catch (e) {
|
||||
logger.info(
|
||||
"Bad signature from key ID " + keyId + " userID " + this._userId +
|
||||
" device ID " + device.deviceId + " fingerprint: " +
|
||||
device.getFingerprint(), backupInfo.auth_data, e,
|
||||
);
|
||||
sigInfo.valid = false;
|
||||
}
|
||||
} else {
|
||||
sigInfo.valid = null; // Can't determine validity because we don't have the signing device
|
||||
logger.info("Ignoring signature from unknown key " + keyId);
|
||||
}
|
||||
ret.sigs.push(sigInfo);
|
||||
}
|
||||
|
||||
ret.usable = ret.sigs.some((s) => {
|
||||
return (
|
||||
s.valid && (
|
||||
(s.device && s.deviceTrust.isVerified()) ||
|
||||
(s.crossSigningId)
|
||||
)
|
||||
);
|
||||
});
|
||||
ret.usable |= ret.trusted_locally;
|
||||
return ret;
|
||||
await this._backupManager.checkKeyBackup();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -2785,191 +2615,12 @@ Crypto.prototype.importRoomKeys = function(keys, opts = {}) {
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedules sending all keys waiting to be sent to the backup, if not already
|
||||
* scheduled. Retries if necessary.
|
||||
*
|
||||
* @param {number} maxDelay Maximum delay to wait in ms. 0 means no delay.
|
||||
*/
|
||||
Crypto.prototype.scheduleKeyBackupSend = async function(maxDelay = 10000) {
|
||||
if (this._sendingBackups) return;
|
||||
|
||||
this._sendingBackups = true;
|
||||
|
||||
try {
|
||||
// wait between 0 and `maxDelay` seconds, to avoid backup
|
||||
// requests from different clients hitting the server all at
|
||||
// the same time when a new key is sent
|
||||
const delay = Math.random() * maxDelay;
|
||||
await sleep(delay);
|
||||
let numFailures = 0; // number of consecutive failures
|
||||
while (1) {
|
||||
if (!this.backupKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const numBackedUp =
|
||||
await this._backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
|
||||
if (numBackedUp === 0) {
|
||||
// no sessions left needing backup: we're done
|
||||
return;
|
||||
}
|
||||
numFailures = 0;
|
||||
} catch (err) {
|
||||
numFailures++;
|
||||
logger.log("Key backup request failed", err);
|
||||
if (err.data) {
|
||||
if (
|
||||
err.data.errcode == 'M_NOT_FOUND' ||
|
||||
err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION'
|
||||
) {
|
||||
// Re-check key backup status on error, so we can be
|
||||
// sure to present the current situation when asked.
|
||||
await this.checkKeyBackup();
|
||||
// Backup version has changed or this backup version
|
||||
// has been deleted
|
||||
this.emit("crypto.keyBackupFailed", err.data.errcode);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (numFailures) {
|
||||
// exponential backoff if we have failures
|
||||
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this._sendingBackups = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Take some e2e keys waiting to be backed up and send them
|
||||
* to the backup.
|
||||
*
|
||||
* @param {integer} limit Maximum number of keys to back up
|
||||
* @returns {integer} Number of sessions backed up
|
||||
*/
|
||||
Crypto.prototype._backupPendingKeys = async function(limit) {
|
||||
const sessions = await this._cryptoStore.getSessionsNeedingBackup(limit);
|
||||
if (!sessions.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let remaining = await this._cryptoStore.countSessionsNeedingBackup();
|
||||
this.emit("crypto.keyBackupSessionsRemaining", remaining);
|
||||
|
||||
const data = {};
|
||||
for (const session of sessions) {
|
||||
const roomId = session.sessionData.room_id;
|
||||
if (data[roomId] === undefined) {
|
||||
data[roomId] = { sessions: {} };
|
||||
}
|
||||
|
||||
const sessionData = await this._olmDevice.exportInboundGroupSession(
|
||||
session.senderKey, session.sessionId, session.sessionData,
|
||||
);
|
||||
sessionData.algorithm = olmlib.MEGOLM_ALGORITHM;
|
||||
delete sessionData.session_id;
|
||||
delete sessionData.room_id;
|
||||
const firstKnownIndex = sessionData.first_known_index;
|
||||
delete sessionData.first_known_index;
|
||||
const encrypted = this.backupKey.encrypt(JSON.stringify(sessionData));
|
||||
|
||||
const forwardedCount =
|
||||
(sessionData.forwarding_curve25519_key_chain || []).length;
|
||||
|
||||
const userId = this._deviceList.getUserByIdentityKey(
|
||||
olmlib.MEGOLM_ALGORITHM, session.senderKey,
|
||||
);
|
||||
const device = this._deviceList.getDeviceByIdentityKey(
|
||||
olmlib.MEGOLM_ALGORITHM, session.senderKey,
|
||||
);
|
||||
const verified = this._checkDeviceInfoTrust(userId, device).isVerified();
|
||||
|
||||
data[roomId]['sessions'][session.sessionId] = {
|
||||
first_message_index: firstKnownIndex,
|
||||
forwarded_count: forwardedCount,
|
||||
is_verified: verified,
|
||||
session_data: encrypted,
|
||||
};
|
||||
}
|
||||
|
||||
await this._baseApis.sendKeyBackup(
|
||||
undefined, undefined, this.backupInfo.version,
|
||||
{ rooms: data },
|
||||
);
|
||||
|
||||
await this._cryptoStore.unmarkSessionsNeedingBackup(sessions);
|
||||
remaining = await this._cryptoStore.countSessionsNeedingBackup();
|
||||
this.emit("crypto.keyBackupSessionsRemaining", remaining);
|
||||
|
||||
return sessions.length;
|
||||
};
|
||||
|
||||
Crypto.prototype.backupGroupSession = async function(
|
||||
roomId, senderKey, forwardingCurve25519KeyChain,
|
||||
sessionId, sessionKey, keysClaimed,
|
||||
exportFormat,
|
||||
) {
|
||||
await this._cryptoStore.markSessionsNeedingBackup([{
|
||||
senderKey: senderKey,
|
||||
sessionId: sessionId,
|
||||
}]);
|
||||
|
||||
if (this.backupInfo) {
|
||||
// don't wait for this to complete: it will delay so
|
||||
// happens in the background
|
||||
this.scheduleKeyBackupSend();
|
||||
}
|
||||
// if this.backupInfo is not set, then the keys will be backed up when
|
||||
// client.enableKeyBackup is called
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks all group sessions as needing to be backed up and schedules them to
|
||||
* upload in the background as soon as possible.
|
||||
*/
|
||||
Crypto.prototype.scheduleAllGroupSessionsForBackup = async function() {
|
||||
await this.flagAllGroupSessionsForBackup();
|
||||
|
||||
// Schedule keys to upload in the background as soon as possible.
|
||||
this.scheduleKeyBackupSend(0 /* maxDelay */);
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks all group sessions as needing to be backed up without scheduling
|
||||
* them to upload in the background.
|
||||
* @returns {Promise<int>} Resolves to the number of sessions now requiring a backup
|
||||
* (which will be equal to the number of sessions in the store).
|
||||
*/
|
||||
Crypto.prototype.flagAllGroupSessionsForBackup = async function() {
|
||||
await this._cryptoStore.doTxn(
|
||||
'readwrite',
|
||||
[
|
||||
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
|
||||
IndexedDBCryptoStore.STORE_BACKUP,
|
||||
],
|
||||
(txn) => {
|
||||
this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
|
||||
if (session !== null) {
|
||||
this._cryptoStore.markSessionsNeedingBackup([session], txn);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const remaining = await this._cryptoStore.countSessionsNeedingBackup();
|
||||
this.emit("crypto.keyBackupSessionsRemaining", remaining);
|
||||
return remaining;
|
||||
};
|
||||
|
||||
/**
|
||||
* Counts the number of end to end session keys that are waiting to be backed up
|
||||
* @returns {Promise<int>} Resolves to the number of sessions requiring backup
|
||||
*/
|
||||
Crypto.prototype.countSessionsNeedingBackup = function() {
|
||||
return this._cryptoStore.countSessionsNeedingBackup();
|
||||
return this._backupManager.countSessionsNeedingBackup();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -3345,10 +2996,10 @@ Crypto.prototype._onRoomKeyEvent = function(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._checkedForBackup) {
|
||||
if (!this._backupManager.checkedForBackup) {
|
||||
// don't bother awaiting on this - the important thing is that we retry if we
|
||||
// haven't managed to check before
|
||||
this._checkAndStartKeyBackup();
|
||||
this._backupManager.checkAndStart();
|
||||
}
|
||||
|
||||
const alg = this._getRoomDecryptor(content.room_id, content.algorithm);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { DeviceInfo } from "./deviceinfo";
|
||||
|
||||
export interface IKeyBackupSession {
|
||||
first_message_index: number; // eslint-disable-line camelcase
|
||||
forwarded_count: number; // eslint-disable-line camelcase
|
||||
is_verified: boolean; // eslint-disable-line camelcase
|
||||
session_data: { // eslint-disable-line camelcase
|
||||
ciphertext: string;
|
||||
ephemeral: string;
|
||||
mac: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IKeyBackupRoomSessions {
|
||||
[sessionId: string]: IKeyBackupSession;
|
||||
}
|
||||
|
||||
export interface IKeyBackupVersion {
|
||||
algorithm: string;
|
||||
auth_data: { // eslint-disable-line camelcase
|
||||
public_key: string; // eslint-disable-line camelcase
|
||||
signatures: ISignatures;
|
||||
};
|
||||
count: number;
|
||||
etag: string;
|
||||
version: string; // number contained within
|
||||
}
|
||||
|
||||
// TODO: Verify types
|
||||
export interface IKeyBackupTrustInfo {
|
||||
/**
|
||||
* is the backup trusted, true if there is a sig that is valid & from a trusted device
|
||||
*/
|
||||
usable: boolean[];
|
||||
sigs: {
|
||||
valid: boolean[];
|
||||
device: DeviceInfo[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface IKeyBackupPrepareOpts {
|
||||
secureSecretStorage: boolean;
|
||||
}
|
||||
|
||||
export interface IKeyBackupRestoreResult {
|
||||
total: number;
|
||||
imported: number;
|
||||
}
|
||||
|
||||
export interface IKeyBackupRestoreOpts {
|
||||
cacheCompleteCallback?: () => void;
|
||||
progressCallback?: ({ stage: string }) => void;
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export async function encryptMessageForDevice(
|
||||
*
|
||||
* @param {module:crypto/OlmDevice} olmDevice
|
||||
*
|
||||
* @param {module:base-apis~MatrixBaseApis} baseApis
|
||||
* @param {MatrixClient} baseApis
|
||||
*
|
||||
* @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
|
||||
* map from userid to list of devices to ensure sessions for
|
||||
@@ -168,7 +168,7 @@ export async function getExistingOlmSessions(
|
||||
*
|
||||
* @param {module:crypto/OlmDevice} olmDevice
|
||||
*
|
||||
* @param {module:base-apis~MatrixBaseApis} baseApis
|
||||
* @param {MatrixClient} baseApis
|
||||
*
|
||||
* @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
|
||||
* map from userid to list of devices to ensure sessions for
|
||||
|
||||
@@ -49,9 +49,10 @@ export class VerificationBase extends EventEmitter {
|
||||
*
|
||||
* @class
|
||||
*
|
||||
* @param {module:base-apis~Channel} channel the verification channel to send verification messages over.
|
||||
* @param {Object} channel the verification channel to send verification messages over.
|
||||
* TODO: Channel types
|
||||
*
|
||||
* @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
|
||||
* @param {MatrixClient} baseApis base matrix api interface
|
||||
*
|
||||
* @param {string} userId the user ID that is being verified
|
||||
*
|
||||
@@ -291,7 +292,7 @@ export class VerificationBase extends EventEmitter {
|
||||
await verifier(keyId, device, keyInfo);
|
||||
verifiedDevices.push(deviceId);
|
||||
} else {
|
||||
const crossSigningInfo = this._baseApis._crypto._deviceList
|
||||
const crossSigningInfo = this._baseApis.crypto._deviceList
|
||||
.getStoredCrossSigningForUser(userId);
|
||||
if (crossSigningInfo && crossSigningInfo.getId() === deviceId) {
|
||||
await verifier(keyId, DeviceInfo.fromStorage({
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "./client";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
|
||||
export type EventMapper = (obj: any) => MatrixEvent;
|
||||
|
||||
export interface MapperOpts {
|
||||
preventReEmit?: boolean;
|
||||
decrypt?: boolean;
|
||||
}
|
||||
|
||||
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
|
||||
const preventReEmit = Boolean(options.preventReEmit);
|
||||
const decrypt = options.decrypt !== false;
|
||||
|
||||
function mapper(plainOldJsObject) {
|
||||
const event = new MatrixEvent(plainOldJsObject);
|
||||
if (event.isEncrypted()) {
|
||||
if (!preventReEmit) {
|
||||
client.reEmitter.reEmit(event, [
|
||||
"Event.decrypted",
|
||||
]);
|
||||
}
|
||||
if (decrypt) {
|
||||
client.decryptEventIfNeeded(event);
|
||||
}
|
||||
}
|
||||
if (!preventReEmit) {
|
||||
client.reEmitter.reEmit(event, ["Event.replaced"]);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
return mapper;
|
||||
}
|
||||
+3
-50
@@ -1,7 +1,5 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-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.
|
||||
@@ -16,17 +14,11 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type Request from "request";
|
||||
|
||||
import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store";
|
||||
import { LocalStorageCryptoStore } from "./crypto/store/localStorage-crypto-store";
|
||||
import { IndexedDBCryptoStore } from "./crypto/store/indexeddb-crypto-store";
|
||||
import { MemoryStore } from "./store/memory";
|
||||
import { StubStore } from "./store/stub";
|
||||
import { LocalIndexedDBStoreBackend } from "./store/indexeddb-local-backend";
|
||||
import { RemoteIndexedDBStoreBackend } from "./store/indexeddb-remote-backend";
|
||||
import { MatrixScheduler } from "./scheduler";
|
||||
import { MatrixClient } from "./client";
|
||||
import { ICreateClientOpts } from "./client";
|
||||
|
||||
export * from "./client";
|
||||
export * from "./http-api";
|
||||
@@ -94,11 +86,6 @@ export function wrapRequest(wrapper) {
|
||||
};
|
||||
}
|
||||
|
||||
type Store =
|
||||
StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend;
|
||||
|
||||
type CryptoStore = MemoryCryptoStore | LocalStorageCryptoStore | IndexedDBCryptoStore;
|
||||
|
||||
let cryptoStoreFactory = () => new MemoryCryptoStore;
|
||||
|
||||
/**
|
||||
@@ -111,41 +98,6 @@ export function setCryptoStoreFactory(fac) {
|
||||
cryptoStoreFactory = fac;
|
||||
}
|
||||
|
||||
export interface ICreateClientOpts {
|
||||
baseUrl: string;
|
||||
idBaseUrl?: string;
|
||||
store?: Store;
|
||||
cryptoStore?: CryptoStore;
|
||||
scheduler?: MatrixScheduler;
|
||||
request?: Request;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
accessToken?: string;
|
||||
identityServer?: any;
|
||||
localTimeoutMs?: number;
|
||||
useAuthorizationHeader?: boolean;
|
||||
timelineSupport?: boolean;
|
||||
queryParams?: Record<string, unknown>;
|
||||
deviceToImport?: {
|
||||
olmDevice: {
|
||||
pickledAccount: string;
|
||||
sessions: Array<Record<string, any>>;
|
||||
pickleKey: string;
|
||||
};
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
};
|
||||
pickleKey?: string;
|
||||
sessionStore?: any;
|
||||
unstableClientRelationAggregation?: boolean;
|
||||
verificationMethods?: Array<any>;
|
||||
forceTURN?: boolean;
|
||||
iceCandidatePoolSize?: number,
|
||||
supportsCallTransfer?: boolean,
|
||||
fallbackICEServerAllowed?: boolean;
|
||||
cryptoCallbacks?: ICryptoCallbacks;
|
||||
}
|
||||
|
||||
export interface ICryptoCallbacks {
|
||||
getCrossSigningKey?: (keyType: string, pubKey: Uint8Array) => Promise<Uint8Array>;
|
||||
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
|
||||
@@ -166,6 +118,7 @@ export interface ICryptoCallbacks {
|
||||
keyInfo: ISecretStorageKeyInfo,
|
||||
checkFunc: (Uint8Array) => void,
|
||||
) => Promise<Uint8Array>;
|
||||
getBackupKey?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
// TODO: Move this to `SecretStorage` once converted
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "../client";
|
||||
import { IEncryptedFile, UNSTABLE_MSC3089_BRANCH } from "../@types/event";
|
||||
import { MatrixEvent } from "./event";
|
||||
|
||||
/**
|
||||
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference
|
||||
* to a file (leaf) in the tree. Note that this is UNSTABLE and subject to breaking changes
|
||||
* without notice.
|
||||
*/
|
||||
export class MSC3089Branch {
|
||||
public constructor(private client: MatrixClient, public readonly indexEvent: MatrixEvent) {
|
||||
// Nothing to do
|
||||
}
|
||||
|
||||
/**
|
||||
* The file ID.
|
||||
*/
|
||||
public get id(): string {
|
||||
return this.indexEvent.getStateKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this branch is active/valid.
|
||||
*/
|
||||
public get isActive(): boolean {
|
||||
return this.indexEvent.getContent()["active"] === true;
|
||||
}
|
||||
|
||||
private get roomId(): string {
|
||||
return this.indexEvent.getRoomId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file from the tree.
|
||||
* @returns {Promise<void>} Resolves when complete.
|
||||
*/
|
||||
public async delete(): Promise<void> {
|
||||
await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, {}, this.id);
|
||||
await this.client.redactEvent(this.roomId, this.id);
|
||||
|
||||
// TODO: Delete edit history as well
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the name for this file.
|
||||
* @returns {string} The name, or "Unnamed File" if unknown.
|
||||
*/
|
||||
public getName(): string {
|
||||
return this.indexEvent.getContent()['name'] || "Unnamed File";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name for this file.
|
||||
* @param {string} name The new name for this file.
|
||||
* @returns {Promise<void>} Resolves when complete.
|
||||
*/
|
||||
public setName(name: string): Promise<void> {
|
||||
return this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, {
|
||||
...this.indexEvent.getContent(),
|
||||
name: name,
|
||||
}, this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets information about the file needed to download it.
|
||||
* @returns {Promise<{info: IEncryptedFile, httpUrl: string}>} Information about the file.
|
||||
*/
|
||||
public async getFileInfo(): Promise<{ info: IEncryptedFile, httpUrl: string }> {
|
||||
const room = this.client.getRoom(this.roomId);
|
||||
if (!room) throw new Error("Unknown room");
|
||||
|
||||
const timeline = await this.client.getEventTimeline(room.getUnfilteredTimelineSet(), this.id);
|
||||
if (!timeline) throw new Error("Failed to get timeline for room event");
|
||||
|
||||
const event = timeline.getEvents().find(e => e.getId() === this.id);
|
||||
if (!event) throw new Error("Failed to find event");
|
||||
|
||||
// Sometimes the event context doesn't decrypt for us, so do that.
|
||||
await this.client.decryptEventIfNeeded(event, { emit: false, isRetry: false });
|
||||
|
||||
const file = event.getContent()['file'];
|
||||
const httpUrl = this.client.mxcUrlToHttp(file['url']);
|
||||
|
||||
return { info: file, httpUrl: httpUrl };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "../client";
|
||||
import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event";
|
||||
import { Room } from "./room";
|
||||
import { logger } from "../logger";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { averageBetweenStrings, DEFAULT_ALPHABET, lexicographicCompare, nextString, prevString } from "../utils";
|
||||
import { MSC3089Branch } from "./MSC3089Branch";
|
||||
|
||||
/**
|
||||
* The recommended defaults for a tree space's power levels. Note that this
|
||||
* is UNSTABLE and subject to breaking changes without notice.
|
||||
*/
|
||||
export const DEFAULT_TREE_POWER_LEVELS_TEMPLATE = {
|
||||
// Owner
|
||||
invite: 100,
|
||||
kick: 100,
|
||||
ban: 100,
|
||||
|
||||
// Editor
|
||||
redact: 50,
|
||||
state_default: 50,
|
||||
events_default: 50,
|
||||
|
||||
// Viewer
|
||||
users_default: 0,
|
||||
|
||||
// Mixed
|
||||
events: {
|
||||
[EventType.RoomPowerLevels]: 100,
|
||||
[EventType.RoomHistoryVisibility]: 100,
|
||||
[EventType.RoomTombstone]: 100,
|
||||
[EventType.RoomEncryption]: 100,
|
||||
[EventType.RoomName]: 50,
|
||||
[EventType.RoomMessage]: 50,
|
||||
[EventType.RoomMessageEncrypted]: 50,
|
||||
[EventType.Sticker]: 50,
|
||||
},
|
||||
|
||||
users: {}, // defined by calling code
|
||||
};
|
||||
|
||||
/**
|
||||
* Ease-of-use representation for power levels represented as simple roles.
|
||||
* Note that this is UNSTABLE and subject to breaking changes without notice.
|
||||
*/
|
||||
export enum TreePermissions {
|
||||
Viewer = "viewer", // Default
|
||||
Editor = "editor", // "Moderator" or ~PL50
|
||||
Owner = "owner", // "Admin" or PL100
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089)
|
||||
* file tree Space. Note that this is UNSTABLE and subject to breaking changes
|
||||
* without notice.
|
||||
*/
|
||||
export class MSC3089TreeSpace {
|
||||
public readonly room: Room;
|
||||
|
||||
public constructor(private client: MatrixClient, public readonly roomId: string) {
|
||||
this.room = this.client.getRoom(this.roomId);
|
||||
|
||||
if (!this.room) throw new Error("Unknown room");
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntactic sugar for room ID of the Space.
|
||||
*/
|
||||
public get id(): string {
|
||||
return this.roomId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not this is a top level space.
|
||||
*/
|
||||
public get isTopLevel(): boolean {
|
||||
// XXX: This is absolutely not how you find out if the space is top level
|
||||
// but is safe for a managed usecase like we offer in the SDK.
|
||||
const parentEvents = this.room.currentState.getStateEvents(EventType.SpaceParent);
|
||||
if (!parentEvents?.length) return true;
|
||||
return parentEvents.every(e => !e.getContent()?.['via']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name of the tree space.
|
||||
* @param {string} name The new name for the space.
|
||||
* @returns {Promise<void>} Resolves when complete.
|
||||
*/
|
||||
public setName(name: string): Promise<void> {
|
||||
return this.client.sendStateEvent(this.roomId, EventType.RoomName, { name }, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Invites a user to the tree space. They will be given the default Viewer
|
||||
* permission level unless specified elsewhere.
|
||||
* @param {string} userId The user ID to invite.
|
||||
* @returns {Promise<void>} Resolves when complete.
|
||||
*/
|
||||
public invite(userId: string): Promise<void> {
|
||||
// TODO: [@@TR] Reliable invites
|
||||
// TODO: [@@TR] Share keys
|
||||
return this.client.invite(this.roomId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the permissions of a user to the given role. Note that if setting a user
|
||||
* to Owner then they will NOT be able to be demoted. If the user does not have
|
||||
* permission to change the power level of the target, an error will be thrown.
|
||||
* @param {string} userId The user ID to change the role of.
|
||||
* @param {TreePermissions} role The role to assign.
|
||||
* @returns {Promise<void>} Resolves when complete.
|
||||
*/
|
||||
public async setPermissions(userId: string, role: TreePermissions): Promise<void> {
|
||||
const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
|
||||
if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels");
|
||||
|
||||
const pls = currentPls.getContent() || {};
|
||||
const viewLevel = pls['users_default'] || 0;
|
||||
const editLevel = pls['events_default'] || 50;
|
||||
const adminLevel = pls['events']?.[EventType.RoomPowerLevels] || 100;
|
||||
|
||||
const users = pls['users'] || {};
|
||||
switch (role) {
|
||||
case TreePermissions.Viewer:
|
||||
users[userId] = viewLevel;
|
||||
break;
|
||||
case TreePermissions.Editor:
|
||||
users[userId] = editLevel;
|
||||
break;
|
||||
case TreePermissions.Owner:
|
||||
users[userId] = adminLevel;
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid role: " + role);
|
||||
}
|
||||
pls['users'] = users;
|
||||
|
||||
return this.client.sendStateEvent(this.roomId, EventType.RoomPowerLevels, pls, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a directory under this tree space, represented as another tree space.
|
||||
* @param {string} name The name for the directory.
|
||||
* @returns {Promise<MSC3089TreeSpace>} Resolves to the created directory.
|
||||
*/
|
||||
public async createDirectory(name: string): Promise<MSC3089TreeSpace> {
|
||||
const directory = await this.client.unstableCreateFileTree(name);
|
||||
|
||||
await this.client.sendStateEvent(this.roomId, EventType.SpaceChild, {
|
||||
via: [this.client.getDomain()],
|
||||
}, directory.roomId);
|
||||
|
||||
await this.client.sendStateEvent(directory.roomId, EventType.SpaceParent, {
|
||||
via: [this.client.getDomain()],
|
||||
}, this.roomId);
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of all known immediate subdirectories to this tree space.
|
||||
* @returns {MSC3089TreeSpace[]} The tree spaces (directories). May be empty, but not null.
|
||||
*/
|
||||
public getDirectories(): MSC3089TreeSpace[] {
|
||||
const trees: MSC3089TreeSpace[] = [];
|
||||
const children = this.room.currentState.getStateEvents(EventType.SpaceChild);
|
||||
for (const child of children) {
|
||||
try {
|
||||
const tree = this.client.unstableGetFileTreeSpace(child.getStateKey());
|
||||
if (tree) trees.push(tree);
|
||||
} catch (e) {
|
||||
logger.warn("Unable to create tree space instance for listing. Are we joined?", e);
|
||||
}
|
||||
}
|
||||
return trees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a subdirectory of a given ID under this tree space. Note that this will not recurse
|
||||
* into children and instead only look one level deep.
|
||||
* @param {string} roomId The room ID (directory ID) to find.
|
||||
* @returns {MSC3089TreeSpace} The directory, or falsy if not found.
|
||||
*/
|
||||
public getDirectory(roomId: string): MSC3089TreeSpace {
|
||||
return this.getDirectories().find(r => r.roomId === roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the tree, kicking all members and deleting **all subdirectories**.
|
||||
* @returns {Promise<void>} Resolves when complete.
|
||||
*/
|
||||
public async delete(): Promise<void> {
|
||||
const subdirectories = this.getDirectories();
|
||||
for (const dir of subdirectories) {
|
||||
await dir.delete();
|
||||
}
|
||||
|
||||
const kickMemberships = ["invite", "knock", "join"];
|
||||
const members = this.room.currentState.getStateEvents(EventType.RoomMember);
|
||||
for (const member of members) {
|
||||
const isNotUs = member.getStateKey() !== this.client.getUserId();
|
||||
if (isNotUs && kickMemberships.includes(member.getContent()['membership'])) {
|
||||
await this.client.kick(this.roomId, member.getStateKey(), "Room deleted");
|
||||
}
|
||||
}
|
||||
|
||||
await this.client.leave(this.roomId);
|
||||
}
|
||||
|
||||
private getOrderedChildren(children: MatrixEvent[]): { roomId: string, order: string }[] {
|
||||
const ordered: { roomId: string, order: string }[] = children
|
||||
.map(c => ({ roomId: c.getStateKey(), order: c.getContent()['order'] }));
|
||||
ordered.sort((a, b) => {
|
||||
if (a.order && !b.order) {
|
||||
return -1;
|
||||
} else if (!a.order && b.order) {
|
||||
return 1;
|
||||
} else if (!a.order && !b.order) {
|
||||
const roomA = this.client.getRoom(a.roomId);
|
||||
const roomB = this.client.getRoom(b.roomId);
|
||||
if (!roomA || !roomB) { // just don't bother trying to do more partial sorting
|
||||
return lexicographicCompare(a.roomId, b.roomId);
|
||||
}
|
||||
|
||||
const createTsA = roomA.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0;
|
||||
const createTsB = roomB.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0;
|
||||
if (createTsA === createTsB) {
|
||||
return lexicographicCompare(a.roomId, b.roomId);
|
||||
}
|
||||
return createTsA - createTsB;
|
||||
} else { // both not-null orders
|
||||
return lexicographicCompare(a.order, b.order);
|
||||
}
|
||||
});
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private getParentRoom(): Room {
|
||||
const parents = this.room.currentState.getStateEvents(EventType.SpaceParent);
|
||||
const parent = parents[0]; // XXX: Wild assumption
|
||||
if (!parent) throw new Error("Expected to have a parent in a non-top level space");
|
||||
|
||||
// XXX: We are assuming the parent is a valid tree space.
|
||||
// We probably don't need to validate the parent room state for this usecase though.
|
||||
const parentRoom = this.client.getRoom(parent.getStateKey());
|
||||
if (!parentRoom) throw new Error("Unable to locate room for parent");
|
||||
|
||||
return parentRoom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current order index for this directory. Note that if this is the top level space
|
||||
* then -1 will be returned.
|
||||
* @returns {number} The order index of this space.
|
||||
*/
|
||||
public getOrder(): number {
|
||||
if (this.isTopLevel) return -1;
|
||||
|
||||
const parentRoom = this.getParentRoom();
|
||||
const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild);
|
||||
const ordered = this.getOrderedChildren(children);
|
||||
|
||||
return ordered.findIndex(c => c.roomId === this.roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the order index for this directory within its parent. Note that if this is a top level
|
||||
* space then an error will be thrown. -1 can be used to move the child to the start, and numbers
|
||||
* larger than the number of children can be used to move the child to the end.
|
||||
* @param {number} index The new order index for this space.
|
||||
* @returns {Promise<void>} Resolves when complete.
|
||||
* @throws Throws if this is a top level space.
|
||||
*/
|
||||
public async setOrder(index: number): Promise<void> {
|
||||
if (this.isTopLevel) throw new Error("Cannot set order of top level spaces currently");
|
||||
|
||||
const parentRoom = this.getParentRoom();
|
||||
const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild);
|
||||
const ordered = this.getOrderedChildren(children);
|
||||
index = Math.max(Math.min(index, ordered.length - 1), 0);
|
||||
|
||||
const currentIndex = this.getOrder();
|
||||
const movingUp = currentIndex < index;
|
||||
if (movingUp && index === (ordered.length - 1)) {
|
||||
index--;
|
||||
} else if (!movingUp && index === 0) {
|
||||
index++;
|
||||
}
|
||||
|
||||
const prev = ordered[movingUp ? index : (index - 1)];
|
||||
const next = ordered[movingUp ? (index + 1) : index];
|
||||
|
||||
let newOrder = DEFAULT_ALPHABET[0];
|
||||
let ensureBeforeIsSane = false;
|
||||
if (!prev) {
|
||||
// Move to front
|
||||
if (next?.order) {
|
||||
newOrder = prevString(next.order);
|
||||
}
|
||||
} else if (index === (ordered.length - 1)) {
|
||||
// Move to back
|
||||
if (next?.order) {
|
||||
newOrder = nextString(next.order);
|
||||
}
|
||||
} else {
|
||||
// Move somewhere in the middle
|
||||
const startOrder = prev?.order;
|
||||
const endOrder = next?.order;
|
||||
if (startOrder && endOrder) {
|
||||
if (startOrder === endOrder) {
|
||||
// Error case: just move +1 to break out of awful math
|
||||
newOrder = nextString(startOrder);
|
||||
} else {
|
||||
newOrder = averageBetweenStrings(startOrder, endOrder);
|
||||
}
|
||||
} else {
|
||||
if (startOrder) {
|
||||
// We're at the end (endOrder is null, so no explicit order)
|
||||
newOrder = nextString(startOrder);
|
||||
} else if (endOrder) {
|
||||
// We're at the start (startOrder is null, so nothing before us)
|
||||
newOrder = prevString(endOrder);
|
||||
} else {
|
||||
// Both points are unknown. We're likely in a range where all the children
|
||||
// don't have particular order values, so we may need to update them too.
|
||||
// The other possibility is there's only us as a child, but we should have
|
||||
// shown up in the other states.
|
||||
ensureBeforeIsSane = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ensureBeforeIsSane) {
|
||||
// We were asked by the order algorithm to prepare the moving space for a landing
|
||||
// in the undefined order part of the order array, which means we need to update the
|
||||
// spaces that come before it with a stable order value.
|
||||
let lastOrder: string;
|
||||
for (let i = 0; i <= index; i++) {
|
||||
const target = ordered[i];
|
||||
if (i === 0) {
|
||||
lastOrder = target.order;
|
||||
}
|
||||
if (!target.order) {
|
||||
// XXX: We should be creating gaps to avoid conflicts
|
||||
lastOrder = lastOrder ? nextString(lastOrder) : DEFAULT_ALPHABET[0];
|
||||
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId);
|
||||
const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
|
||||
await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, {
|
||||
...content,
|
||||
order: lastOrder,
|
||||
}, target.roomId);
|
||||
} else {
|
||||
lastOrder = target.order;
|
||||
}
|
||||
}
|
||||
newOrder = nextString(lastOrder);
|
||||
}
|
||||
|
||||
// TODO: Deal with order conflicts by reordering
|
||||
|
||||
// Now we can finally update our own order state
|
||||
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId);
|
||||
const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
|
||||
await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, {
|
||||
...content,
|
||||
|
||||
// TODO: Safely constrain to 50 character limit required by spaces.
|
||||
order: newOrder,
|
||||
}, this.roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates (uploads) a new file to this tree. The file must have already been encrypted for the room.
|
||||
* @param {string} name The name of the file.
|
||||
* @param {ArrayBuffer} encryptedContents The encrypted contents.
|
||||
* @param {Partial<IEncryptedFile>} info The encrypted file information.
|
||||
* @returns {Promise<void>} Resolves when uploaded.
|
||||
*/
|
||||
public async createFile(
|
||||
name: string,
|
||||
encryptedContents: ArrayBuffer, info: Partial<IEncryptedFile>,
|
||||
): Promise<void> {
|
||||
const mxc = await this.client.uploadContent(new Blob([encryptedContents]), {
|
||||
includeFilename: false,
|
||||
onlyContentUri: true,
|
||||
});
|
||||
info.url = mxc;
|
||||
|
||||
const res = await this.client.sendMessage(this.roomId, {
|
||||
msgtype: MsgType.File,
|
||||
body: name,
|
||||
url: mxc,
|
||||
file: info,
|
||||
[UNSTABLE_MSC3089_LEAF.name]: {},
|
||||
});
|
||||
|
||||
await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, {
|
||||
active: true,
|
||||
name: name,
|
||||
}, res['event_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a file from the tree.
|
||||
* @param {string} fileEventId The event ID of the file.
|
||||
* @returns {MSC3089Branch} The file, or falsy if not found.
|
||||
*/
|
||||
public getFile(fileEventId: string): MSC3089Branch {
|
||||
const branch = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name, fileEventId);
|
||||
return branch ? new MSC3089Branch(this.client, branch) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an array of all known files for the tree.
|
||||
* @returns {MSC3089Branch[]} The known files. May be empty, but not null.
|
||||
*/
|
||||
public listFiles(): MSC3089Branch[] {
|
||||
const branches = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name) ?? [];
|
||||
return branches.map(e => new MSC3089Branch(this.client, e)).filter(b => b.isActive);
|
||||
}
|
||||
}
|
||||
@@ -332,7 +332,7 @@ export class Relations extends EventEmitter {
|
||||
}, null);
|
||||
|
||||
if (lastReplacement?.shouldAttemptDecryption()) {
|
||||
await lastReplacement.attemptDecryption(this._room._client._crypto);
|
||||
await lastReplacement.attemptDecryption(this._room._client.crypto);
|
||||
} else if (lastReplacement?.isBeingDecrypted()) {
|
||||
await lastReplacement._decryptionPromise;
|
||||
}
|
||||
|
||||
+39
-28
@@ -45,6 +45,7 @@ import * as utils from "../utils";
|
||||
* @prop {string} membership The membership state for this room member e.g. 'join'.
|
||||
* @prop {Object} events The events describing this RoomMember.
|
||||
* @prop {MatrixEvent} events.member The m.room.member event for this RoomMember.
|
||||
* @prop {boolean} disambiguate True if the member's name is disambiguated.
|
||||
*/
|
||||
export function RoomMember(roomId, userId) {
|
||||
this.roomId = roomId;
|
||||
@@ -61,6 +62,7 @@ export function RoomMember(roomId, userId) {
|
||||
};
|
||||
this._isOutOfBand = false;
|
||||
this._updateModifiedTime();
|
||||
this.disambiguate = false;
|
||||
}
|
||||
utils.inherits(RoomMember, EventEmitter);
|
||||
|
||||
@@ -90,6 +92,8 @@ RoomMember.prototype.isOutOfBand = function() {
|
||||
* @fires module:client~MatrixClient#event:"RoomMember.membership"
|
||||
*/
|
||||
RoomMember.prototype.setMembershipEvent = function(event, roomState) {
|
||||
const displayName = event.getDirectionalContent().displayname;
|
||||
|
||||
if (event.getType() !== "m.room.member") {
|
||||
return;
|
||||
}
|
||||
@@ -101,11 +105,19 @@ RoomMember.prototype.setMembershipEvent = function(event, roomState) {
|
||||
const oldMembership = this.membership;
|
||||
this.membership = event.getDirectionalContent().membership;
|
||||
|
||||
this.disambiguate = shouldDisambiguate(
|
||||
this.userId,
|
||||
displayName,
|
||||
roomState,
|
||||
);
|
||||
|
||||
const oldName = this.name;
|
||||
this.name = calculateDisplayName(
|
||||
this.userId,
|
||||
event.getDirectionalContent().displayname,
|
||||
roomState);
|
||||
displayName,
|
||||
roomState,
|
||||
this.disambiguate,
|
||||
);
|
||||
|
||||
this.rawDisplayName = event.getDirectionalContent().displayname || this.userId;
|
||||
if (oldMembership !== this.membership) {
|
||||
@@ -292,43 +304,42 @@ RoomMember.prototype.getMxcAvatarUrl = function() {
|
||||
const MXID_PATTERN = /@.+:.+/;
|
||||
const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
|
||||
|
||||
function calculateDisplayName(selfUserId, displayName, roomState) {
|
||||
if (!displayName || displayName === selfUserId) {
|
||||
return selfUserId;
|
||||
}
|
||||
function shouldDisambiguate(selfUserId, displayName, roomState) {
|
||||
if (!displayName || displayName === selfUserId) return false;
|
||||
|
||||
// First check if the displayname is something we consider truthy
|
||||
// after stripping it of zero width characters and padding spaces
|
||||
if (!utils.removeHiddenChars(displayName)) {
|
||||
return selfUserId;
|
||||
}
|
||||
if (!utils.removeHiddenChars(displayName)) return false;
|
||||
|
||||
if (!roomState) {
|
||||
return displayName;
|
||||
}
|
||||
if (!roomState) return false;
|
||||
|
||||
// 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 = MXID_PATTERN.test(displayName);
|
||||
if (MXID_PATTERN.test(displayName)) return true;
|
||||
|
||||
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 = LTR_RTL_PATTERN.test(displayName);
|
||||
}
|
||||
// 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.
|
||||
if (LTR_RTL_PATTERN.test(displayName)) return true;
|
||||
|
||||
if (!disambiguate) {
|
||||
// Also show mxid if there are other people with the same or similar
|
||||
// displayname, after hidden character removal.
|
||||
const userIds = roomState.getUserIdsWithDisplayName(displayName);
|
||||
disambiguate = userIds.some((u) => u !== selfUserId);
|
||||
}
|
||||
// Also show mxid if there are other people with the same or similar
|
||||
// displayname, after hidden character removal.
|
||||
const userIds = roomState.getUserIdsWithDisplayName(displayName);
|
||||
if (userIds.some((u) => u !== selfUserId)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function calculateDisplayName(selfUserId, displayName, roomState, disambiguate) {
|
||||
if (disambiguate) return displayName + " (" + selfUserId + ")";
|
||||
|
||||
if (!displayName || displayName === selfUserId) return selfUserId;
|
||||
|
||||
// First check if the displayname is something we consider truthy
|
||||
// after stripping it of zero width characters and padding spaces
|
||||
if (!utils.removeHiddenChars(displayName)) return selfUserId;
|
||||
|
||||
if (disambiguate) {
|
||||
return displayName + " (" + selfUserId + ")";
|
||||
}
|
||||
return displayName;
|
||||
}
|
||||
|
||||
|
||||
@@ -349,6 +349,11 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
|
||||
self._updateMember(member);
|
||||
self.emit("RoomState.members", event, self, member);
|
||||
} else if (event.getType() === "m.room.power_levels") {
|
||||
// events with unknown state keys should be ignored
|
||||
// and should not aggregate onto members power levels
|
||||
if (event.getStateKey() !== "") {
|
||||
return;
|
||||
}
|
||||
const members = Object.values(self.members);
|
||||
members.forEach(function(member) {
|
||||
// We only propagate `RoomState.members` event if the
|
||||
|
||||
+7
-7
@@ -192,13 +192,13 @@ export function Room(roomId, client, myUserId, opts) {
|
||||
|
||||
if (this._opts.pendingEventOrdering == "detached") {
|
||||
this._pendingEventList = [];
|
||||
const serializedPendingEventList = client._sessionStore.store.getItem(pendingEventsKey(this.roomId));
|
||||
const serializedPendingEventList = client.sessionStore.store.getItem(pendingEventsKey(this.roomId));
|
||||
if (serializedPendingEventList) {
|
||||
JSON.parse(serializedPendingEventList)
|
||||
.forEach(async serializedEvent => {
|
||||
const event = new MatrixEvent(serializedEvent);
|
||||
if (event.getType() === "m.room.encrypted") {
|
||||
await event.attemptDecryption(this._client._crypto);
|
||||
await event.attemptDecryption(this._client.crypto);
|
||||
}
|
||||
event.setStatus(EventStatus.NOT_SENT);
|
||||
this.addPendingEvent(event, event.getTxnId());
|
||||
@@ -255,7 +255,7 @@ Room.prototype.decryptCriticalEvents = function() {
|
||||
.slice(readReceiptTimelineIndex)
|
||||
.filter(event => event.shouldAttemptDecryption())
|
||||
.reverse()
|
||||
.map(event => event.attemptDecryption(this._client._crypto, { isRetry: true }));
|
||||
.map(event => event.attemptDecryption(this._client.crypto, { isRetry: true }));
|
||||
|
||||
return Promise.allSettled(decryptionPromises);
|
||||
};
|
||||
@@ -272,7 +272,7 @@ Room.prototype.decryptAllEvents = function() {
|
||||
.getEvents()
|
||||
.filter(event => event.shouldAttemptDecryption())
|
||||
.reverse()
|
||||
.map(event => event.attemptDecryption(this._client._crypto, { isRetry: true }));
|
||||
.map(event => event.attemptDecryption(this._client.crypto, { isRetry: true }));
|
||||
|
||||
return Promise.allSettled(decryptionPromises);
|
||||
};
|
||||
@@ -632,7 +632,7 @@ Room.prototype._loadMembersFromServer = async function() {
|
||||
});
|
||||
const path = utils.encodeUri("/rooms/$roomId/members?" + queryString,
|
||||
{ $roomId: this.roomId });
|
||||
const http = this._client._http;
|
||||
const http = this._client.http;
|
||||
const response = await http.authedRequest(undefined, "GET", path);
|
||||
return response.chunk;
|
||||
};
|
||||
@@ -674,7 +674,7 @@ Room.prototype.loadMembersIfNeeded = function() {
|
||||
this.currentState.setOutOfBandMembers(result.memberEvents);
|
||||
// now the members are loaded, start to track the e2e devices if needed
|
||||
if (this._client.isCryptoEnabled() && this._client.isRoomEncrypted(this.roomId)) {
|
||||
this._client._crypto.trackRoomDevices(this.roomId);
|
||||
this._client.crypto.trackRoomDevices(this.roomId);
|
||||
}
|
||||
return result.fromServer;
|
||||
}).catch((err) => {
|
||||
@@ -1387,7 +1387,7 @@ Room.prototype._savePendingEvents = function() {
|
||||
return isEventEncrypted || !isRoomEncrypted;
|
||||
});
|
||||
|
||||
const { store } = this._client._sessionStore;
|
||||
const { store } = this._client.sessionStore;
|
||||
if (this._pendingEventList.length > 0) {
|
||||
store.setItem(
|
||||
pendingEventsKey(this.roomId),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Copyright 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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// TODO: Merge this with sync.js once converted
|
||||
|
||||
export enum SyncState {
|
||||
Error = "ERROR",
|
||||
Prepared = "PREPARED",
|
||||
Stopped = "STOPPED",
|
||||
Syncing = "SYNCING",
|
||||
Catchup = "CATCHUP",
|
||||
Reconnecting = "RECONNECTING",
|
||||
}
|
||||
+9
-19
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
* TODO:
|
||||
* This class mainly serves to take all the syncing logic out of client.js and
|
||||
* into a separate file. It's all very fluid, and this class gut wrenches a lot
|
||||
* of MatrixClient props (e.g. _http). Given we want to support WebSockets as
|
||||
* of MatrixClient props (e.g. http). Given we want to support WebSockets as
|
||||
* an alternative syncing API, we may want to have a proper syncing interface
|
||||
* for HTTP and WS at some point.
|
||||
*/
|
||||
@@ -209,7 +209,7 @@ SyncApi.prototype.syncLeftRooms = function() {
|
||||
getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter,
|
||||
).then(function(filterId) {
|
||||
qps.filter = filterId;
|
||||
return client._http.authedRequest(
|
||||
return client.http.authedRequest(
|
||||
undefined, "GET", "/sync", qps, undefined, localTimeoutMs,
|
||||
);
|
||||
}).then(function(data) {
|
||||
@@ -349,7 +349,7 @@ SyncApi.prototype._peekPoll = function(peekRoom, token) {
|
||||
|
||||
const self = this;
|
||||
// FIXME: gut wrenching; hard-coded timeout values
|
||||
this.client._http.authedRequest(undefined, "GET", "/events", {
|
||||
this.client.http.authedRequest(undefined, "GET", "/events", {
|
||||
room_id: peekRoom.roomId,
|
||||
timeout: 30 * 1000,
|
||||
from: token,
|
||||
@@ -551,7 +551,7 @@ SyncApi.prototype.sync = function() {
|
||||
}
|
||||
try {
|
||||
debuglog("Storing client options...");
|
||||
await this.client._storeClientOptions();
|
||||
await this.client.storeClientOptions();
|
||||
debuglog("Stored client options");
|
||||
} catch (err) {
|
||||
logger.error("Storing client options failed", err);
|
||||
@@ -815,7 +815,7 @@ SyncApi.prototype._sync = async function(syncOptions) {
|
||||
|
||||
SyncApi.prototype._doSyncRequest = function(syncOptions, syncToken) {
|
||||
const qps = this._getSyncParams(syncOptions, syncToken);
|
||||
return this.client._http.authedRequest(
|
||||
return this.client.http.authedRequest(
|
||||
undefined, "GET", "/sync", qps, undefined,
|
||||
qps.timeout + BUFFER_PERIOD_MS,
|
||||
);
|
||||
@@ -1426,7 +1426,7 @@ SyncApi.prototype._pokeKeepAlive = function(connDidFail) {
|
||||
}
|
||||
}
|
||||
|
||||
this.client._http.request(
|
||||
this.client.http.request(
|
||||
undefined, // callback
|
||||
"GET", "/_matrix/client/versions",
|
||||
undefined, // queryParams
|
||||
@@ -1671,19 +1671,9 @@ SyncApi.prototype._processEventsForNotifs = function(room, timelineEventList) {
|
||||
* @return {string}
|
||||
*/
|
||||
SyncApi.prototype._getGuestFilter = function() {
|
||||
const guestRooms = this.client._guestRooms; // FIXME: horrible gut-wrenching
|
||||
if (!guestRooms) {
|
||||
return "{}";
|
||||
}
|
||||
// we just need to specify the filter inline if we're a guest because guests
|
||||
// can't create filters.
|
||||
return JSON.stringify({
|
||||
room: {
|
||||
timeline: {
|
||||
limit: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Dev note: This used to be conditional to return a filter of 20 events maximum, but
|
||||
// the condition never went to the other branch. This is now hardcoded.
|
||||
return "{}";
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+161
-1
@@ -398,7 +398,7 @@ export function ensureNoTrailingSlash(url: string): string {
|
||||
}
|
||||
|
||||
// Returns a promise which resolves with a given value after the given number of ms
|
||||
export function sleep<T>(ms: number, value: T): Promise<T> {
|
||||
export function sleep<T>(ms: number, value?: T): Promise<T> {
|
||||
return new Promise((resolve => {
|
||||
setTimeout(resolve, ms, value);
|
||||
}));
|
||||
@@ -456,3 +456,163 @@ export function setCrypto(c: Object) {
|
||||
export function getCrypto(): Object {
|
||||
return crypto;
|
||||
}
|
||||
|
||||
// String averaging inspired by https://stackoverflow.com/a/2510816
|
||||
// Dev note: We make the alphabet a string because it's easier to write syntactically
|
||||
// than arrays. Thankfully, strings implement the useful parts of the Array interface
|
||||
// anyhow.
|
||||
|
||||
/**
|
||||
* The default alphabet used by string averaging in this SDK. This matches
|
||||
* all usefully printable ASCII characters (0x20-0x7E, inclusive).
|
||||
*/
|
||||
export const DEFAULT_ALPHABET = (() => {
|
||||
let str = "";
|
||||
for (let c = 0x20; c <= 0x7E; c++) {
|
||||
str += String.fromCharCode(c);
|
||||
}
|
||||
return str;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Pads a string using the given alphabet as a base. The returned string will be
|
||||
* padded at the end with the first character in the alphabet.
|
||||
*
|
||||
* This is intended for use with string averaging.
|
||||
* @param {string} s The string to pad.
|
||||
* @param {number} n The length to pad to.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {string} The padded string.
|
||||
*/
|
||||
export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): string {
|
||||
return s.padEnd(n, alphabet[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a baseN number to a string, where N is the alphabet's length.
|
||||
*
|
||||
* This is intended for use with string averaging.
|
||||
* @param {bigint} n The baseN number.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {string} The baseN number encoded as a string from the alphabet.
|
||||
*/
|
||||
export function baseToString(n: bigint, alphabet = DEFAULT_ALPHABET): string {
|
||||
// Developer note: the stringToBase() function offsets the character set by 1 so that repeated
|
||||
// characters (ie: "aaaaaa" in a..z) don't come out as zero. We have to reverse this here as
|
||||
// otherwise we'll be wrong in our conversion. Undoing a +1 before an exponent isn't very fun
|
||||
// though, so we rely on a lengthy amount of `x - 1` and integer division rules to reach a
|
||||
// sane state. This also means we have to do rollover detection: see below.
|
||||
|
||||
const len = BigInt(alphabet.length);
|
||||
if (n <= len) {
|
||||
return alphabet[Number(n) - 1] ?? "";
|
||||
}
|
||||
|
||||
let d = n / len;
|
||||
let r = Number(n % len) - 1;
|
||||
|
||||
// Rollover detection: if the remainder is negative, it means that the string needs
|
||||
// to roll over by 1 character downwards (ie: in a..z, the previous to "aaa" would be
|
||||
// "zz").
|
||||
if (r < 0) {
|
||||
d -= BigInt(Math.abs(r)); // abs() is just to be clear what we're doing. Could also `+= r`.
|
||||
r = Number(len) - 1;
|
||||
}
|
||||
|
||||
return baseToString(d, alphabet) + alphabet[r];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string to a baseN number, where N is the alphabet's length.
|
||||
*
|
||||
* This is intended for use with string averaging.
|
||||
* @param {string} s The string to convert to a number.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {bigint} The baseN number.
|
||||
*/
|
||||
export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint {
|
||||
const len = BigInt(alphabet.length);
|
||||
|
||||
// In our conversion to baseN we do a couple performance optimizations to avoid using
|
||||
// excess CPU and such. To create baseN numbers, the input string needs to be reversed
|
||||
// so the exponents stack up appropriately, as the last character in the unreversed
|
||||
// string has less impact than the first character (in "abc" the A is a lot more important
|
||||
// for lexicographic sorts). We also do a trick with the character codes to optimize the
|
||||
// alphabet lookup, avoiding an index scan of `alphabet.indexOf(reversedStr[i])` - we know
|
||||
// that the alphabet and (theoretically) the input string are constrained on character sets
|
||||
// and thus can do simple subtraction to end up with the same result.
|
||||
|
||||
// Developer caution: we carefully cast to BigInt here to avoid losing precision. We cannot
|
||||
// rely on Math.pow() (for example) to be capable of handling our insane numbers.
|
||||
|
||||
let result = BigInt(0);
|
||||
for (let i = s.length - 1, j = BigInt(0); i >= 0; i--, j++) {
|
||||
const charIndex = s.charCodeAt(i) - alphabet.charCodeAt(0);
|
||||
|
||||
// We add 1 to the char index to offset the whole numbering scheme. We unpack this in
|
||||
// the baseToString() function.
|
||||
result += BigInt(1 + charIndex) * (len ** j);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Averages two strings, returning the midpoint between them. This is accomplished by
|
||||
* converting both to baseN numbers (where N is the alphabet's length) then averaging
|
||||
* those before re-encoding as a string.
|
||||
* @param {string} a The first string.
|
||||
* @param {string} b The second string.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {string} The midpoint between the strings, as a string.
|
||||
*/
|
||||
export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_ALPHABET): string {
|
||||
const padN = Math.max(a.length, b.length);
|
||||
const baseA = stringToBase(alphabetPad(a, padN, alphabet), alphabet);
|
||||
const baseB = stringToBase(alphabetPad(b, padN, alphabet), alphabet);
|
||||
const avg = (baseA + baseB) / BigInt(2);
|
||||
|
||||
// Detect integer division conflicts. This happens when two numbers are divided too close so
|
||||
// we lose a .5 precision. We need to add a padding character in these cases.
|
||||
if (avg === baseA || avg == baseB) {
|
||||
return baseToString(avg, alphabet) + alphabet[0];
|
||||
}
|
||||
|
||||
return baseToString(avg, alphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next string using the alphabet provided. This is done by converting the
|
||||
* string to a baseN number, where N is the alphabet's length, then adding 1 before
|
||||
* converting back to a string.
|
||||
* @param {string} s The string to start at.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {string} The string which follows the input string.
|
||||
*/
|
||||
export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string {
|
||||
return baseToString(stringToBase(s, alphabet) + BigInt(1), alphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the previous string using the alphabet provided. This is done by converting the
|
||||
* string to a baseN number, where N is the alphabet's length, then subtracting 1 before
|
||||
* converting back to a string.
|
||||
* @param {string} s The string to start at.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {string} The string which precedes the input string.
|
||||
*/
|
||||
export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string {
|
||||
return baseToString(stringToBase(s, alphabet) - BigInt(1), alphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares strings lexicographically as a sort-safe function.
|
||||
* @param {string} a The first (reference) string.
|
||||
* @param {string} b The second (compare) string.
|
||||
* @returns {number} Negative if the reference string is before the compare string;
|
||||
* positive if the reference string is after; and zero if equal.
|
||||
*/
|
||||
export function lexicographicCompare(a: string, b: string): number {
|
||||
// Dev note: this exists because I'm sad that you can use math operators on strings, so I've
|
||||
// hidden the operation in this function.
|
||||
return (a < b) ? -1 : ((a === b) ? 0 : 1);
|
||||
}
|
||||
|
||||
+29
-52
@@ -192,7 +192,12 @@ export enum CallErrorCode {
|
||||
/**
|
||||
* The remote party is busy
|
||||
*/
|
||||
UserBusy = 'user_busy'
|
||||
UserBusy = 'user_busy',
|
||||
|
||||
/**
|
||||
* We transferred the call off to somewhere else
|
||||
*/
|
||||
Transfered = 'transferred',
|
||||
}
|
||||
|
||||
enum ConstraintsType {
|
||||
@@ -289,10 +294,6 @@ export class MatrixCall extends EventEmitter {
|
||||
// This flag represents whether we want the other party to be on hold
|
||||
private remoteOnHold;
|
||||
|
||||
// and this one we set when we're transitioning out of the hold state because we
|
||||
// can't tell the difference between that and the other party holding us
|
||||
private unholdingRemote;
|
||||
|
||||
private micMuted;
|
||||
private vidMuted;
|
||||
|
||||
@@ -344,7 +345,6 @@ export class MatrixCall extends EventEmitter {
|
||||
this.makingOffer = false;
|
||||
|
||||
this.remoteOnHold = false;
|
||||
this.unholdingRemote = false;
|
||||
this.micMuted = false;
|
||||
this.vidMuted = false;
|
||||
|
||||
@@ -441,7 +441,7 @@ export class MatrixCall extends EventEmitter {
|
||||
* @returns {Array<CallFeed>} local CallFeeds
|
||||
*/
|
||||
public getLocalFeeds(): Array<CallFeed> {
|
||||
return this.feeds.filter((feed) => {return feed.isLocal()});
|
||||
return this.feeds.filter((feed) => feed.isLocal());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,7 +449,7 @@ export class MatrixCall extends EventEmitter {
|
||||
* @returns {Array<CallFeed>} remote CallFeeds
|
||||
*/
|
||||
public getRemoteFeeds(): Array<CallFeed> {
|
||||
return this.feeds.filter((feed) => {return !feed.isLocal()});
|
||||
return this.feeds.filter((feed) => !feed.isLocal());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,7 +511,7 @@ export class MatrixCall extends EventEmitter {
|
||||
|
||||
// make sure we have valid turn creds. Unless something's gone wrong, it should
|
||||
// poll and keep the credentials valid so this should be instant.
|
||||
const haveTurnCreds = await this.client._checkTurnServers();
|
||||
const haveTurnCreds = await this.client.checkTurnServers();
|
||||
if (!haveTurnCreds) {
|
||||
logger.warn("Failed to get TURN credentials! Proceeding with call anyway...");
|
||||
}
|
||||
@@ -530,7 +530,7 @@ export class MatrixCall extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteStream = this.feeds.find((feed) => {return !feed.isLocal()})?.stream;
|
||||
const remoteStream = this.feeds.find((feed) => !feed.isLocal())?.stream;
|
||||
|
||||
// According to previous comments in this file, firefox at some point did not
|
||||
// add streams until media started ariving on them. Testing latest firefox
|
||||
@@ -598,7 +598,7 @@ export class MatrixCall extends EventEmitter {
|
||||
this.gotUserMediaForAnswer(mediaStream);
|
||||
} catch (e) {
|
||||
this.getUserMediaFailed(e);
|
||||
return
|
||||
return;
|
||||
}
|
||||
} else if (this.localAVStream) {
|
||||
this.gotUserMediaForAnswer(this.localAVStream);
|
||||
@@ -728,12 +728,12 @@ export class MatrixCall extends EventEmitter {
|
||||
setRemoteOnHold(onHold: boolean) {
|
||||
if (this.isRemoteOnHold() === onHold) return;
|
||||
this.remoteOnHold = onHold;
|
||||
if (!onHold) this.unholdingRemote = true;
|
||||
|
||||
for (const tranceiver of this.peerConn.getTransceivers()) {
|
||||
// We set 'inactive' rather than 'sendonly' because we're not planning on
|
||||
// playing music etc. to the other side.
|
||||
tranceiver.direction = onHold ? 'inactive' : 'sendrecv';
|
||||
// We don't send hold music or anything so we're not actually
|
||||
// sending anything, but sendrecv is fairly standard for hold and
|
||||
// it makes it a lot easier to figure out who's put who on hold.
|
||||
tranceiver.direction = onHold ? 'sendonly' : 'sendrecv';
|
||||
}
|
||||
this.updateMuteStatus();
|
||||
|
||||
@@ -742,15 +742,11 @@ export class MatrixCall extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Indicates whether we are 'on hold' to the remote party (ie. if true,
|
||||
* they cannot hear us). Note that this will return true when we put the
|
||||
* remote on hold too due to the way hold is implemented (since we don't
|
||||
* wish to play hold music when we put a call on hold, we use 'inactive'
|
||||
* rather than 'sendonly')
|
||||
* they cannot hear us).
|
||||
* @returns true if the other party has put us on hold
|
||||
*/
|
||||
isLocalOnHold(): boolean {
|
||||
if (this.state !== CallState.Connected) return false;
|
||||
if (this.unholdingRemote) return false;
|
||||
|
||||
let callOnHold = true;
|
||||
|
||||
@@ -846,10 +842,10 @@ export class MatrixCall extends EventEmitter {
|
||||
},
|
||||
} as MCallAnswer;
|
||||
|
||||
if (this.client._supportsCallTransfer) {
|
||||
if (this.client.supportsCallTransfer) {
|
||||
answerContent.capabilities = {
|
||||
'm.call.transferee': true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// We have just taken the local description from the peerconnection which will
|
||||
@@ -1096,31 +1092,12 @@ export class MatrixCall extends EventEmitter {
|
||||
|
||||
const prevLocalOnHold = this.isLocalOnHold();
|
||||
|
||||
if (description.type === 'answer') {
|
||||
// whenever we get an answer back, clear the flag we set whilst trying to un-hold
|
||||
// the other party: the state of the channels now reflects reality
|
||||
this.unholdingRemote = false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.peerConn.setRemoteDescription(description);
|
||||
|
||||
if (description.type === 'offer') {
|
||||
// First we sent the direction of the tranciever to what we'd like it to be,
|
||||
// irresepective of whether the other side has us on hold - so just whether we
|
||||
// want the call to be on hold or not. This is necessary because in a few lines,
|
||||
// we'll adjust the direction and unless we do this too, we'll never come off hold.
|
||||
for (const tranceiver of this.peerConn.getTransceivers()) {
|
||||
tranceiver.direction = this.isRemoteOnHold() ? 'inactive' : 'sendrecv';
|
||||
}
|
||||
const localDescription = await this.peerConn.createAnswer();
|
||||
await this.peerConn.setLocalDescription(localDescription);
|
||||
// Now we've got our answer, set the direction to the outcome of the negotiation.
|
||||
// We need to do this otherwise Firefox will notice that the direction is not the
|
||||
// currentDirection and try to negotiate itself off hold again.
|
||||
for (const tranceiver of this.peerConn.getTransceivers()) {
|
||||
tranceiver.direction = tranceiver.currentDirection;
|
||||
}
|
||||
|
||||
this.sendVoipEvent(EventType.CallNegotiate, {
|
||||
description: this.peerConn.localDescription,
|
||||
@@ -1169,7 +1146,7 @@ export class MatrixCall extends EventEmitter {
|
||||
} catch (err) {
|
||||
logger.debug("Error setting local description!", err);
|
||||
this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true);
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.peerConn.iceGatheringState === 'gathering') {
|
||||
@@ -1194,10 +1171,10 @@ export class MatrixCall extends EventEmitter {
|
||||
content.description = this.peerConn.localDescription;
|
||||
}
|
||||
|
||||
if (this.client._supportsCallTransfer) {
|
||||
if (this.client.supportsCallTransfer) {
|
||||
content.capabilities = {
|
||||
'm.call.transferee': true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get rid of any candidates waiting to be sent: they'll be included in the local
|
||||
@@ -1304,7 +1281,7 @@ export class MatrixCall extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldRemoteStream = this.feeds.find((feed) => {return !feed.isLocal()})?.stream;
|
||||
const oldRemoteStream = this.feeds.find((feed) => !feed.isLocal())?.stream;
|
||||
|
||||
// If we already have a stream, check this track is from the same one
|
||||
// Note that we check by ID and always set the remote stream: Chrome appears
|
||||
@@ -1325,7 +1302,7 @@ export class MatrixCall extends EventEmitter {
|
||||
|
||||
logger.debug(`Track id ${ev.track.id} of kind ${ev.track.kind} added`);
|
||||
|
||||
this.pushNewFeed(newRemoteStream, this.getOpponentMember().userId, SDPStreamMetadataPurpose.Usermedia)
|
||||
this.pushNewFeed(newRemoteStream, this.getOpponentMember().userId, SDPStreamMetadataPurpose.Usermedia);
|
||||
|
||||
logger.info("playing remote. stream active? " + newRemoteStream.active);
|
||||
};
|
||||
@@ -1456,7 +1433,7 @@ export class MatrixCall extends EventEmitter {
|
||||
|
||||
await this.sendVoipEvent(EventType.CallReplaces, body);
|
||||
|
||||
await this.terminate(CallParty.Local, CallErrorCode.Replaced, true);
|
||||
await this.terminate(CallParty.Local, CallErrorCode.Transfered, true);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1496,7 +1473,7 @@ export class MatrixCall extends EventEmitter {
|
||||
await this.sendVoipEvent(EventType.CallReplaces, bodyToTransferee);
|
||||
|
||||
await this.terminate(CallParty.Local, CallErrorCode.Replaced, true);
|
||||
await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Replaced, true);
|
||||
await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Transfered, true);
|
||||
}
|
||||
|
||||
private async terminate(hangupParty: CallParty, hangupReason: CallErrorCode, shouldEmit: boolean) {
|
||||
@@ -1592,14 +1569,14 @@ export class MatrixCall extends EventEmitter {
|
||||
private async placeCallWithConstraints(constraints: MediaStreamConstraints) {
|
||||
logger.log("Getting user media with constraints", constraints);
|
||||
// XXX Find a better way to do this
|
||||
this.client._callEventHandler.calls.set(this.callId, this);
|
||||
this.client.callEventHandler.calls.set(this.callId, this);
|
||||
this.setState(CallState.WaitLocalMedia);
|
||||
this.direction = CallDirection.Outbound;
|
||||
this.config = constraints;
|
||||
|
||||
// make sure we have valid turn creds. Unless something's gone wrong, it should
|
||||
// poll and keep the credentials valid so this should be instant.
|
||||
const haveTurnCreds = await this.client._checkTurnServers();
|
||||
const haveTurnCreds = await this.client.checkTurnServers();
|
||||
if (!haveTurnCreds) {
|
||||
logger.warn("Failed to get TURN credentials! Proceeding with call anyway...");
|
||||
}
|
||||
@@ -1621,7 +1598,7 @@ export class MatrixCall extends EventEmitter {
|
||||
const pc = new window.RTCPeerConnection({
|
||||
iceTransportPolicy: this.forceTURN ? 'relay' : undefined,
|
||||
iceServers: this.turnServers,
|
||||
iceCandidatePoolSize: this.client._iceCandidatePoolSize,
|
||||
iceCandidatePoolSize: this.client.iceCandidatePoolSize,
|
||||
});
|
||||
|
||||
// 'connectionstatechange' would be better, but firefox doesn't implement that.
|
||||
@@ -1826,7 +1803,7 @@ export function createNewMatrixCall(client: any, roomId: string, options?: CallO
|
||||
roomId: roomId,
|
||||
turnServers: client.getTurnServers(),
|
||||
// call level options
|
||||
forceTURN: client._forceTURN || optionsForceTURN,
|
||||
forceTURN: client.forceTURN || optionsForceTURN,
|
||||
};
|
||||
const call = new MatrixCall(opts);
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export class CallEventHandler {
|
||||
}
|
||||
this.callEventBuffer = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onEvent = (event: MatrixEvent) => {
|
||||
this.client.decryptEventIfNeeded(event);
|
||||
@@ -117,7 +117,7 @@ export class CallEventHandler {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private eventIsACall(event: MatrixEvent): boolean {
|
||||
const type = event.getType();
|
||||
@@ -155,7 +155,7 @@ export class CallEventHandler {
|
||||
const timeUntilTurnCresExpire = this.client.getTurnServersExpiry() - Date.now();
|
||||
logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
|
||||
call = createNewMatrixCall(this.client, event.getRoomId(), {
|
||||
forceTURN: this.client._forceTURN,
|
||||
forceTURN: this.client.forceTURN,
|
||||
});
|
||||
if (!call) {
|
||||
logger.log(
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import EventEmitter from "events";
|
||||
import { SDPStreamMetadataPurpose } from "./callEventTypes";
|
||||
import MatrixClient from "../client"
|
||||
import { MatrixClient } from "../client";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
|
||||
export enum CallFeedEvent {
|
||||
@@ -31,7 +31,7 @@ export class CallFeed extends EventEmitter {
|
||||
private client: MatrixClient,
|
||||
private roomId: string,
|
||||
) {
|
||||
super()
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2845,8 +2845,8 @@ eslint-config-google@^0.14.0:
|
||||
integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==
|
||||
|
||||
"eslint-plugin-matrix-org@github:matrix-org/eslint-plugin-matrix-org#main":
|
||||
version "0.3.1"
|
||||
resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/b55649a0f48ee27155c1968ed5050b6ddc5afdbe"
|
||||
version "0.3.2"
|
||||
resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/28d392822533a7468be0dd806d0a4ba573a45d74"
|
||||
|
||||
eslint-rule-composer@^0.3.0:
|
||||
version "0.3.0"
|
||||
|
||||
Reference in New Issue
Block a user