From 62114028d566e7f94cb2d0184671ba84b39f298b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 13 Oct 2020 19:05:41 +0100 Subject: [PATCH 001/124] Convert aes, key_passphrase, olmlib, and recoverykey to TS Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 1 + src/@types/global.d.ts | 5 + src/crypto/{aes.js => aes.ts} | 22 +++-- src/crypto/dehydration.ts | 2 +- .../{key_passphrase.js => key_passphrase.ts} | 14 ++- src/crypto/{olmlib.js => olmlib.ts} | 96 +++++++++++++------ src/crypto/{recoverykey.js => recoverykey.ts} | 6 +- src/utils.ts | 7 +- yarn.lock | 9 +- 9 files changed, 115 insertions(+), 47 deletions(-) rename src/crypto/{aes.js => aes.ts} (89%) rename src/crypto/{key_passphrase.js => key_passphrase.ts} (83%) rename src/crypto/{olmlib.js => olmlib.ts} (86%) rename src/crypto/{recoverykey.js => recoverykey.ts} (89%) diff --git a/package.json b/package.json index f76bd0493..09d66306a 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@babel/preset-env": "^7.11.5", "@babel/preset-typescript": "^7.10.4", "@babel/register": "^7.11.5", + "@types/bs58": "^4.0.1", "@types/node": "12", "@types/request": "^2.48.5", "babel-eslint": "^10.1.0", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 85339369f..3d81c5a60 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -23,9 +23,14 @@ declare global { namespace NodeJS { interface Global { localStorage: Storage; + Olm: typeof Olm; } } + interface Crypto { + webkitSubtle?: Window["crypto"]["subtle"]; + } + interface MediaDevices { // This is experimental and types don't know about it yet // https://github.com/microsoft/TypeScript/issues/33232 diff --git a/src/crypto/aes.js b/src/crypto/aes.ts similarity index 89% rename from src/crypto/aes.js rename to src/crypto/aes.ts index 1556413f7..f85bae9bd 100644 --- a/src/crypto/aes.js +++ b/src/crypto/aes.ts @@ -31,7 +31,7 @@ const zerosalt = new Uint8Array(8); * @param {string} name the name of the secret * @param {string} ivStr the initialization vector to use */ -async function encryptNode(data, key, name, ivStr) { +async function encryptNode(data: string, key: Uint8Array, name: string, ivStr?: string) { const crypto = getCrypto(); if (!crypto) { throw new Error("No usable crypto implementation"); @@ -75,7 +75,7 @@ async function encryptNode(data, key, name, ivStr) { * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret */ -async function decryptNode(data, key, name) { +async function decryptNode(data: IData, key: Uint8Array, name: string) { const crypto = getCrypto(); if (!crypto) { throw new Error("No usable crypto implementation"); @@ -120,7 +120,7 @@ function deriveKeysNode(key, name) { * @param {string} name the name of the secret * @param {string} ivStr the initialization vector to use */ -async function encryptBrowser(data, key, name, ivStr) { +async function encryptBrowser(data: string, key: Uint8Array, name: string, ivStr?: string) { let iv; if (ivStr) { iv = decodeBase64(ivStr); @@ -160,6 +160,12 @@ async function encryptBrowser(data, key, name, ivStr) { }; } +interface IData { + ciphertext: string; + iv: string; + mac: string; +} + /** * decrypt a string in the browser * @@ -170,7 +176,7 @@ async function encryptBrowser(data, key, name, ivStr) { * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret */ -async function decryptBrowser(data, key, name) { +async function decryptBrowser(data: IData, key: Uint8Array, name: string) { const [aesKey, hmacKey] = await deriveKeysBrowser(key, name); const ciphertext = decodeBase64(data.ciphertext); @@ -241,11 +247,11 @@ async function deriveKeysBrowser(key, name) { return await Promise.all([aesProm, hmacProm]); } -export function encryptAES(...args) { - return subtleCrypto ? encryptBrowser(...args) : encryptNode(...args); +export function encryptAES(data: string, key: Uint8Array, name: string, ivStr?: string) { + return subtleCrypto ? encryptBrowser(data, key, name, ivStr) : encryptNode(data, key, name, ivStr); } -export function decryptAES(...args) { - return subtleCrypto ? decryptBrowser(...args) : decryptNode(...args); +export function decryptAES(data: IData, key: Uint8Array, name: string) { + return subtleCrypto ? decryptBrowser(data, key, name) : decryptNode(data, key, name); } diff --git a/src/crypto/dehydration.ts b/src/crypto/dehydration.ts index 637fa5ce1..2d450af1e 100644 --- a/src/crypto/dehydration.ts +++ b/src/crypto/dehydration.ts @@ -31,7 +31,7 @@ interface DeviceKeys { signatures?: Signatures; } -interface OneTimeKey { +export interface OneTimeKey { key: string; fallback?: boolean; signatures?: Signatures; diff --git a/src/crypto/key_passphrase.js b/src/crypto/key_passphrase.ts similarity index 83% rename from src/crypto/key_passphrase.js rename to src/crypto/key_passphrase.ts index 47c964726..4961be57a 100644 --- a/src/crypto/key_passphrase.js +++ b/src/crypto/key_passphrase.ts @@ -21,7 +21,15 @@ const DEFAULT_ITERATIONS = 500000; const DEFAULT_BITSIZE = 256; -export async function keyFromAuthData(authData, password) { +interface IAuthData { + /* eslint-disable camelcase */ + private_key_salt: string; + private_key_iterations: number; + private_key_bits?: number; + /* eslint-enable camelcase */ +} + +export async function keyFromAuthData(authData: IAuthData, password: string) { if (!global.Olm) { throw new Error("Olm is not available"); } @@ -40,7 +48,7 @@ export async function keyFromAuthData(authData, password) { ); } -export async function keyFromPassphrase(password) { +export async function keyFromPassphrase(password: string) { if (!global.Olm) { throw new Error("Olm is not available"); } @@ -52,7 +60,7 @@ export async function keyFromPassphrase(password) { return { key, salt, iterations: DEFAULT_ITERATIONS }; } -export async function deriveKey(password, salt, iterations, numBits = DEFAULT_BITSIZE) { +export async function deriveKey(password: string, salt: string, iterations: number, numBits = DEFAULT_BITSIZE) { const subtleCrypto = global.crypto.subtle; const TextEncoder = global.TextEncoder; if (!subtleCrypto || !TextEncoder) { diff --git a/src/crypto/olmlib.js b/src/crypto/olmlib.ts similarity index 86% rename from src/crypto/olmlib.js rename to src/crypto/olmlib.ts index dbdf07dba..ebe705a46 100644 --- a/src/crypto/olmlib.js +++ b/src/crypto/olmlib.ts @@ -22,24 +22,36 @@ limitations under the License. * Utilities common to olm encryption algorithms */ +import anotherjson from "another-json"; +import {PkSigning} from "olm"; + +import OlmDevice from "./OlmDevice"; +import DeviceInfo from "./DeviceInfo"; +import MatrixBaseApis from "../base-apis"; import {logger} from '../logger'; import * as utils from "../utils"; -import anotherjson from "another-json"; +import {OneTimeKey} from "./dehydration"; + +enum Algorithm { + Olm = "m.olm.v1.curve25519-aes-sha2", + Megolm = "m.megolm.v1.aes-sha2", + MegolmBackup = "m.megolm_backup.v1.curve25519-aes-sha2", +} /** * matrix algorithm tag for olm */ -export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; +export const OLM_ALGORITHM = Algorithm.Olm; /** * matrix algorithm tag for megolm */ -export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; +export const MEGOLM_ALGORITHM = Algorithm.Megolm; /** * matrix algorithm tag for megolm backups */ -export const MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2"; +export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup; /** @@ -59,9 +71,13 @@ export const MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2"; * has been encrypted into `resultsObject` */ export async function encryptMessageForDevice( - resultsObject, - ourUserId, ourDeviceId, olmDevice, recipientUserId, recipientDevice, - payloadFields, + resultsObject: Record, + ourUserId: string, + ourDeviceId: string, + olmDevice: OlmDevice, + recipientUserId: string, + recipientDevice: DeviceInfo, + payloadFields: Record, ) { const deviceKey = recipientDevice.getIdentityKey(); const sessionId = await olmDevice.getSessionIdForDevice(deviceKey); @@ -130,7 +146,9 @@ export async function encryptMessageForDevice( * a map from userId to deviceId to {@link module:crypto~OlmSessionResult} */ export async function getExistingOlmSessions( - olmDevice, baseApis, devicesByUser, + olmDevice: OlmDevice, + baseApis: MatrixBaseApis, + devicesByUser: Record, ) { const devicesWithoutSession = {}; const sessions = {}; @@ -188,9 +206,16 @@ export async function getExistingOlmSessions( * {@link module:crypto~OlmSessionResult} */ export async function ensureOlmSessionsForDevices( - olmDevice, baseApis, devicesByUser, force, otkTimeout, failedServers, + olmDevice: OlmDevice, + baseApis: MatrixBaseApis, + devicesByUser: Record, + force: boolean, + otkTimeout: number, + failedServers: string[], ) { if (typeof force === "number") { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - backwards compatibility failedServers = otkTimeout; otkTimeout = force; force = false; @@ -200,7 +225,7 @@ export async function ensureOlmSessionsForDevices( // [userId, deviceId], ... ]; const result = {}; - const resolveSession = {}; + const resolveSession: Record = {}; for (const [userId, devices] of Object.entries(devicesByUser)) { result[userId] = {}; @@ -237,9 +262,9 @@ export async function ensureOlmSessionsForDevices( delete olmDevice._sessionsInProgress[key]; resolve(...args); }, - reject: (...args) => { + reject: (e: Error) => { delete olmDevice._sessionsInProgress[key]; - reject(...args); + reject(e); }, }; }, @@ -293,10 +318,10 @@ export async function ensureOlmSessionsForDevices( failedServers.push(...Object.keys(res.failures)); } - const otk_res = res.one_time_keys || {}; + const otkRes = res.one_time_keys || {}; const promises = []; for (const [userId, devices] of Object.entries(devicesByUser)) { - const userRes = otk_res[userId] || {}; + const userRes = otkRes[userId] || {}; for (let j = 0; j < devices.length; j++) { const deviceInfo = devices[j]; const deviceId = deviceInfo.deviceId; @@ -354,7 +379,12 @@ export async function ensureOlmSessionsForDevices( return result; } -async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo) { +async function _verifyKeyAndStartSession( + olmDevice: OlmDevice, + oneTimeKey: OneTimeKey, + userId: string, + deviceInfo: DeviceInfo, +): Promise { const deviceId = deviceInfo.deviceId; try { await verifySignature( @@ -386,6 +416,10 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn return sid; } +interface IObject { + unsigned: object; + signatures: object; +} /** * Verify the signature on an object @@ -404,7 +438,11 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn * or rejects with an Error if it is bad. */ export async function verifySignature( - olmDevice, obj, signingUserId, signingDeviceId, signingKey, + olmDevice: OlmDevice, + obj: OneTimeKey | IObject, + signingUserId: string, + signingDeviceId: string, + signingKey: string, ) { const signKeyId = "ed25519:" + signingDeviceId; const signatures = obj.signatures || {}; @@ -417,7 +455,9 @@ export async function verifySignature( // prepare the canonical json: remove unsigned and signatures, and stringify with // anotherjson const mangledObj = Object.assign({}, obj); - delete mangledObj.unsigned; + if ("unsigned" in mangledObj) { + delete mangledObj.unsigned; + } delete mangledObj.signatures; const json = anotherjson.stringify(mangledObj); @@ -433,14 +473,14 @@ export async function verifySignature( * @param {Olm.PkSigning|Uint8Array} key the signing object or the private key * seed * @param {string} userId The user ID who owns the signing key - * @param {string} pubkey The public key (ignored if key is a seed) + * @param {string} pubKey The public key (ignored if key is a seed) * @returns {string} the signature for the object */ -export function pkSign(obj, key, userId, pubkey) { +export function pkSign(obj: IObject, key: PkSigning, userId: string, pubKey: string): string { let createdKey = false; if (key instanceof Uint8Array) { const keyObj = new global.Olm.PkSigning(); - pubkey = keyObj.init_with_seed(key); + pubKey = keyObj.init_with_seed(key); key = keyObj; createdKey = true; } @@ -452,7 +492,7 @@ export function pkSign(obj, key, userId, pubkey) { const mysigs = sigs[userId] || {}; sigs[userId] = mysigs; - return mysigs['ed25519:' + pubkey] = key.sign(anotherjson.stringify(obj)); + return mysigs['ed25519:' + pubKey] = key.sign(anotherjson.stringify(obj)); } finally { obj.signatures = sigs; if (unsigned) obj.unsigned = unsigned; @@ -465,11 +505,11 @@ export function pkSign(obj, key, userId, pubkey) { /** * Verify a signed JSON object * @param {Object} obj Object to verify - * @param {string} pubkey The public key to use to verify + * @param {string} pubKey The public key to use to verify * @param {string} userId The user ID who signed the object */ -export function pkVerify(obj, pubkey, userId) { - const keyId = "ed25519:" + pubkey; +export function pkVerify(obj: IObject, pubKey: string, userId: string) { + const keyId = "ed25519:" + pubKey; if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) { throw new Error("No signature"); } @@ -480,7 +520,7 @@ export function pkVerify(obj, pubkey, userId) { const unsigned = obj.unsigned; if (obj.unsigned) delete obj.unsigned; try { - util.ed25519_verify(pubkey, anotherjson.stringify(obj), signature); + util.ed25519_verify(pubKey, anotherjson.stringify(obj), signature); } finally { obj.signatures = sigs; if (unsigned) obj.unsigned = unsigned; @@ -493,7 +533,7 @@ export function pkVerify(obj, pubkey, userId) { * @param {Uint8Array} uint8Array The data to encode. * @return {string} The base64. */ -export function encodeBase64(uint8Array) { +export function encodeBase64(uint8Array: ArrayBuffer): string { return Buffer.from(uint8Array).toString("base64"); } @@ -502,7 +542,7 @@ export function encodeBase64(uint8Array) { * @param {Uint8Array} uint8Array The data to encode. * @return {string} The unpadded base64. */ -export function encodeUnpaddedBase64(uint8Array) { +export function encodeUnpaddedBase64(uint8Array: Uint8Array): string { return encodeBase64(uint8Array).replace(/=+$/g, ''); } @@ -511,6 +551,6 @@ export function encodeUnpaddedBase64(uint8Array) { * @param {string} base64 The base64 to decode. * @return {Uint8Array} The decoded data. */ -export function decodeBase64(base64) { +export function decodeBase64(base64: string): Uint8Array { return Buffer.from(base64, "base64"); } diff --git a/src/crypto/recoverykey.js b/src/crypto/recoverykey.ts similarity index 89% rename from src/crypto/recoverykey.js rename to src/crypto/recoverykey.ts index 7fb9cf44a..94a5ae8e2 100644 --- a/src/crypto/recoverykey.js +++ b/src/crypto/recoverykey.ts @@ -20,7 +20,7 @@ import bs58 from 'bs58'; // (which are also base58 encoded, but bitcoin's involve a lot more hashing) const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; -export function encodeRecoveryKey(key) { +export function encodeRecoveryKey(key: ArrayLike): string { const buf = new Buffer(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1); buf.set(OLM_RECOVERY_KEY_PREFIX, 0); buf.set(key, OLM_RECOVERY_KEY_PREFIX.length); @@ -35,8 +35,8 @@ export function encodeRecoveryKey(key) { return base58key.match(/.{1,4}/g).join(" "); } -export function decodeRecoveryKey(recoverykey) { - const result = bs58.decode(recoverykey.replace(/ /g, '')); +export function decodeRecoveryKey(recoveryKey: string): ArrayLike { + const result = bs58.decode(recoveryKey.replace(/ /g, '')); let parity = 0; for (const b of result) { diff --git a/src/utils.ts b/src/utils.ts index 95b3c11c5..efc0d59ba 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -21,6 +21,7 @@ limitations under the License. */ import unhomoglyph from 'unhomoglyph'; +import type NodeCrypto from "crypto"; /** * Encode a dictionary of query parameters. @@ -749,12 +750,12 @@ export function promiseTry(fn: () => T): Promise { // Matrix SDK without needing to `require("crypto")`, which will fail in // browsers. So `index.ts` will call `setCrypto` to store it, and when we need // it, we can call `getCrypto`. -let crypto: Object; +let crypto: typeof NodeCrypto; -export function setCrypto(c: Object) { +export function setCrypto(c: typeof NodeCrypto) { crypto = c; } -export function getCrypto(): Object { +export function getCrypto(): typeof NodeCrypto { return crypto; } diff --git a/yarn.lock b/yarn.lock index 71718d355..c4d15e80b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1340,6 +1340,13 @@ dependencies: "@types/babel-types" "*" +"@types/bs58@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/bs58/-/bs58-4.0.1.tgz#3d51222aab067786d3bc3740a84a7f5a0effaa37" + integrity sha512-yfAgiWgVLjFCmRv8zAcOIHywYATEwiTVccTLnRp6UxTNavT55M9d/uhK3T03St/+8/z/wW+CRjGKUNmEqoHHCA== + dependencies: + base-x "^3.0.6" + "@types/caseless@*": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" @@ -1912,7 +1919,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= -base-x@^3.0.2: +base-x@^3.0.2, base-x@^3.0.6: version "3.0.8" resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.8.tgz#1e1106c2537f0162e8b52474a557ebb09000018d" integrity sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA== From 13c5c4e4f5e49c2b7ded2dbb81c741b6e8703af5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 13 Oct 2020 19:36:03 +0100 Subject: [PATCH 002/124] Use utf8 instead of utf-8 as per https://nodejs.org/api/buffer.html#buffer_buffers_and_character_encodings Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/crypto/aes.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/crypto/aes.ts b/src/crypto/aes.ts index f85bae9bd..a7ad9e699 100644 --- a/src/crypto/aes.ts +++ b/src/crypto/aes.ts @@ -21,7 +21,7 @@ const subtleCrypto = (typeof window !== "undefined" && window.crypto) ? (window.crypto.subtle || window.crypto.webkitSubtle) : null; // salt for HKDF, with 8 bytes of zeros -const zerosalt = new Uint8Array(8); +const zeroSalt = new Uint8Array(8); /** * encrypt a string in Node.js @@ -52,7 +52,7 @@ async function encryptNode(data: string, key: Uint8Array, name: string, ivStr?: const [aesKey, hmacKey] = deriveKeysNode(key, name); const cipher = crypto.createCipheriv("aes-256-ctr", aesKey, iv); - const ciphertext = cipher.update(data, "utf-8", "base64") + const ciphertext = cipher.update(data, "utf8", "base64") + cipher.final("base64"); const hmac = crypto.createHmac("sha256", hmacKey) @@ -93,21 +93,21 @@ async function decryptNode(data: IData, key: Uint8Array, name: string) { const decipher = crypto.createDecipheriv( "aes-256-ctr", aesKey, decodeBase64(data.iv), ); - return decipher.update(data.ciphertext, "base64", "utf-8") - + decipher.final("utf-8"); + return decipher.update(data.ciphertext, "base64", "utf8") + + decipher.final("utf8"); } function deriveKeysNode(key, name) { const crypto = getCrypto(); - const prk = crypto.createHmac("sha256", zerosalt) + const prk = crypto.createHmac("sha256", zeroSalt) .update(key).digest(); const b = Buffer.alloc(1, 1); const aesKey = crypto.createHmac("sha256", prk) - .update(name, "utf-8").update(b).digest(); + .update(name, "utf8").update(b).digest(); b[0] = 2; const hmacKey = crypto.createHmac("sha256", prk) - .update(aesKey).update(name, "utf-8").update(b).digest(); + .update(aesKey).update(name, "utf8").update(b).digest(); return [aesKey, hmacKey]; } @@ -214,7 +214,7 @@ async function deriveKeysBrowser(key, name) { const keybits = await subtleCrypto.deriveBits( { name: "HKDF", - salt: zerosalt, + salt: zeroSalt, info: (new TextEncoder().encode(name)), hash: "SHA-256", }, From 7845957d0d5ff7923164a0d92b39f894007c915d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 13 Oct 2020 19:43:31 +0100 Subject: [PATCH 003/124] fix Olm types import Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/@types/global.d.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 3d81c5a60..030c37e15 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -15,7 +15,7 @@ limitations under the License. */ // this is needed to tell TS about global.Olm -import * as Olm from "olm"; // eslint-disable-line @typescript-eslint/no-unused-vars +import "olm"; export {}; @@ -23,7 +23,6 @@ declare global { namespace NodeJS { interface Global { localStorage: Storage; - Olm: typeof Olm; } } From 608d7faa2982edca01ebe032bddc6757f8579f77 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Tue, 13 Oct 2020 17:25:58 -0400 Subject: [PATCH 004/124] fix some typescript errors --- src/crypto/aes.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/crypto/aes.ts b/src/crypto/aes.ts index a7ad9e699..679332b4c 100644 --- a/src/crypto/aes.ts +++ b/src/crypto/aes.ts @@ -52,15 +52,17 @@ async function encryptNode(data: string, key: Uint8Array, name: string, ivStr?: const [aesKey, hmacKey] = deriveKeysNode(key, name); const cipher = crypto.createCipheriv("aes-256-ctr", aesKey, iv); - const ciphertext = cipher.update(data, "utf8", "base64") - + cipher.final("base64"); + const ciphertext = Buffer.concat([ + cipher.update(data, "utf8"), + cipher.final(), + ]); const hmac = crypto.createHmac("sha256", hmacKey) - .update(ciphertext, "base64").digest("base64"); + .update(ciphertext).digest("base64"); return { iv: encodeBase64(iv), - ciphertext: ciphertext, + ciphertext: ciphertext.toString("base64"), mac: hmac, }; } @@ -84,7 +86,8 @@ async function decryptNode(data: IData, key: Uint8Array, name: string) { const [aesKey, hmacKey] = deriveKeysNode(key, name); const hmac = crypto.createHmac("sha256", hmacKey) - .update(data.ciphertext, "base64").digest("base64").replace(/=+$/g, ''); + .update(Buffer.from(data.ciphertext, "base64")) + .digest("base64").replace(/=+$/g, ''); if (hmac !== data.mac.replace(/=+$/g, '')) { throw new Error(`Error decrypting secret ${name}: bad MAC`); From df36f0dab4b2d70e9058c8d99692dea5f77f4fab Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 13 Oct 2020 22:34:06 +0100 Subject: [PATCH 005/124] fix couple more errors Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/crypto/aes.ts | 2 ++ src/crypto/olmlib.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/crypto/aes.ts b/src/crypto/aes.ts index a7ad9e699..a7e505657 100644 --- a/src/crypto/aes.ts +++ b/src/crypto/aes.ts @@ -215,6 +215,8 @@ async function deriveKeysBrowser(key, name) { { name: "HKDF", salt: zeroSalt, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 info: (new TextEncoder().encode(name)), hash: "SHA-256", }, diff --git a/src/crypto/olmlib.ts b/src/crypto/olmlib.ts index ebe705a46..5772ad88c 100644 --- a/src/crypto/olmlib.ts +++ b/src/crypto/olmlib.ts @@ -26,7 +26,7 @@ import anotherjson from "another-json"; import {PkSigning} from "olm"; import OlmDevice from "./OlmDevice"; -import DeviceInfo from "./DeviceInfo"; +import DeviceInfo from "./deviceinfo"; import MatrixBaseApis from "../base-apis"; import {logger} from '../logger'; import * as utils from "../utils"; From 0fdfc3ff53e0ba303695bf7834f2505d05979ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sun, 18 Apr 2021 21:01:53 +0200 Subject: [PATCH 006/124] Rework how disambiguation is handled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/models/room-member.js | 64 ++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/src/models/room-member.js b/src/models/room-member.js index 444c45609..1d9d513e5 100644 --- a/src/models/room-member.js +++ b/src/models/room-member.js @@ -61,6 +61,7 @@ export function RoomMember(roomId, userId) { }; this._isOutOfBand = false; this._updateModifiedTime(); + this.disambiguate = false; } utils.inherits(RoomMember, EventEmitter); @@ -90,6 +91,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 +104,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) { @@ -293,43 +304,40 @@ 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; +} + +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; } From e049edd449e96015688386346d45011530229e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Tue, 20 Apr 2021 10:44:17 +0200 Subject: [PATCH 007/124] Add docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/models/room-member.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/models/room-member.js b/src/models/room-member.js index 1d9d513e5..73d22f6db 100644 --- a/src/models/room-member.js +++ b/src/models/room-member.js @@ -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 client show disambiguate display names */ export function RoomMember(roomId, userId) { this.roomId = roomId; From 71c6d71cae60e5a8a5f60918e0a7ff88ea6c85d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Tue, 20 Apr 2021 10:53:39 +0200 Subject: [PATCH 008/124] Fix docs Co-authored-by: Jonathan de Jong --- src/models/room-member.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/room-member.js b/src/models/room-member.js index 73d22f6db..ba937deab 100644 --- a/src/models/room-member.js +++ b/src/models/room-member.js @@ -45,7 +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 client show disambiguate display names + * @prop {boolean} disambiguate True if the client should disambiguate display names */ export function RoomMember(roomId, userId) { this.roomId = roomId; From 628dd7bf41b3b4388c6aaef9ecc0ebe87af7bddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Tue, 20 Apr 2021 11:01:13 +0200 Subject: [PATCH 009/124] Return false by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/models/room-member.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/models/room-member.js b/src/models/room-member.js index 73d22f6db..81ec5ce56 100644 --- a/src/models/room-member.js +++ b/src/models/room-member.js @@ -110,6 +110,7 @@ RoomMember.prototype.setMembershipEvent = function(event, roomState) { displayName, roomState, ); + if (this.userId == "@jboi:jboi.nl") console.log("LOG roomMember", this); const oldName = this.name; this.name = calculateDisplayName( @@ -328,6 +329,8 @@ function shouldDisambiguate(selfUserId, displayName, roomState) { // 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) { From a416fd562bed513c51326ba015061cf37da55075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Tue, 20 Apr 2021 11:11:33 +0200 Subject: [PATCH 010/124] Oops - remove log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/models/room-member.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/models/room-member.js b/src/models/room-member.js index e912511c1..8756e1d52 100644 --- a/src/models/room-member.js +++ b/src/models/room-member.js @@ -110,7 +110,6 @@ RoomMember.prototype.setMembershipEvent = function(event, roomState) { displayName, roomState, ); - if (this.userId == "@jboi:jboi.nl") console.log("LOG roomMember", this); const oldName = this.name; this.name = calculateDisplayName( From e4fbbd56a9fc885555b252e3dce6b3229b19c515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 22 Apr 2021 07:36:53 +0200 Subject: [PATCH 011/124] Improve wording Co-authored-by: Travis Ralston --- src/models/room-member.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/room-member.js b/src/models/room-member.js index 8756e1d52..4a835f1e5 100644 --- a/src/models/room-member.js +++ b/src/models/room-member.js @@ -45,7 +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 client should disambiguate display names + * @prop {boolean} disambiguate True if the member's name is disambiguated. */ export function RoomMember(roomId, userId) { this.roomId = roomId; From bcb4071993d8d656babf08ffad521f506aac53f7 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 1 Jun 2021 11:17:30 +0100 Subject: [PATCH 012/124] Strip hash from urls being previewed to de-duplicate --- src/client.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/client.js b/src/client.js index cb60705ef..4c71adf50 100644 --- a/src/client.js +++ b/src/client.js @@ -3368,6 +3368,10 @@ MatrixClient.prototype.getUrlPreview = function(url, ts, callback) { // Surely 60-second accuracy is enough for anyone. ts = Math.floor(ts / 60000) * 60000; + const parsed = new URL(url); + parsed.hash = ""; // strip the hash as it won't affect the preview + url = parsed.toString(); + const key = ts + "_" + url; // If there's already a request in flight (or we've handled it), return that instead. From 49ef274a83327eae34f96fa12b8736b9a84b26fb Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 4 Jun 2021 17:22:14 +0100 Subject: [PATCH 013/124] Use sendonly for call hold Instead of inactive. Remove the logic for trying to remember who had who on hold, which is now unnecesasary. Appears to work fine with element android & ios too. --- src/webrtc/call.ts | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index b4fee0045..f863b3bfb 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -289,10 +289,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 +340,6 @@ export class MatrixCall extends EventEmitter { this.makingOffer = false; this.remoteOnHold = false; - this.unholdingRemote = false; this.micMuted = false; this.vidMuted = false; @@ -728,12 +723,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 +737,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; @@ -1096,12 +1087,6 @@ 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); From 683aae5ed59da0dd58a341f5469bc014b2a52707 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 7 Jun 2021 09:00:08 +0100 Subject: [PATCH 014/124] Add MSC3230 event type to enum --- src/@types/event.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/@types/event.ts b/src/@types/event.ts index 3c905442b..620693636 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -71,6 +71,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", From 9084b4e7aa9673e8dd2884fe6aa4b5b682508b7c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 4 Jun 2021 18:34:37 -0600 Subject: [PATCH 015/124] Early implementation of MSC3089 (file trees) MSC: https://github.com/matrix-org/matrix-doc/pull/3089 Includes part of MSC3088 (room subtyping): https://github.com/matrix-org/matrix-doc/pull/3088 The NamespacedValue stuff is borrowed from the Extensible Events implementation PR in the react-sdk as a useful thing to put here. When/if the MSCs become stable, we'd convert the values to enums and drop the constants (or keep them for migration purposes, but switch to stable). This flags the whole thing as unstable because it's highly subject to change. --- spec/unit/NamespacedValue.spec.ts | 78 ++++++++++ spec/unit/matrix-client.spec.js | 164 ++++++++++++++++++++ spec/unit/models/MSC3089TreeSpace.spec.ts | 181 ++++++++++++++++++++++ src/@types/event.ts | 30 ++++ src/@types/requests.ts | 3 + src/NamespacedValue.ts | 95 ++++++++++++ src/client.ts | 77 ++++++++- src/models/MSC3089TreeSpace.ts | 138 +++++++++++++++++ 8 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 spec/unit/NamespacedValue.spec.ts create mode 100644 spec/unit/models/MSC3089TreeSpace.spec.ts create mode 100644 src/NamespacedValue.ts create mode 100644 src/models/MSC3089TreeSpace.ts diff --git a/spec/unit/NamespacedValue.spec.ts b/spec/unit/NamespacedValue.spec.ts new file mode 100644 index 000000000..5b0d8e143 --- /dev/null +++ b/spec/unit/NamespacedValue.spec.ts @@ -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"); + } + }); +}); diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.js index 98c6b127e..80ff2e5a1 100644 --- a/spec/unit/matrix-client.spec.js +++ b/spec/unit/matrix-client.spec.js @@ -1,6 +1,16 @@ 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"; jest.useFakeTimers(); @@ -171,6 +181,160 @@ describe("MatrixClient", function() { }); }); + 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: "private_chat", + 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); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts new file mode 100644 index 000000000..4e3f069c2 --- /dev/null +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -0,0 +1,181 @@ +/* +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 } from "../../../src/@types/event"; +import { + DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + MSC3089TreeSpace, + TreePermissions +} from "../../../src/models/MSC3089TreeSpace"; + +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 = { + getRoom: (roomId: string) => { + if (roomId === roomId) { + return room; + } else { + throw new Error("Unexpected fetch for unknown 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); + }); +}); diff --git a/src/@types/event.ts b/src/@types/event.ts index 3c905442b..9e726de77 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -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", @@ -100,3 +102,31 @@ 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"); diff --git a/src/@types/requests.ts b/src/@types/requests.ts index 581b4a1b6..b402bbdcb 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -74,6 +74,9 @@ export interface ICreateRoomOpts { name?: string; topic?: string; preset?: string; + power_level_content_override?: any; + creation_content?: any; + initial_state?: {type: string, state_key: string, content: any}[]; // TODO: Types (next line) invite_3pid?: any[]; // eslint-disable-line camelcase } diff --git a/src/NamespacedValue.ts b/src/NamespacedValue.ts new file mode 100644 index 000000000..e20beab2d --- /dev/null +++ b/src/NamespacedValue.ts @@ -0,0 +1,95 @@ +/* +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 { + public constructor(public readonly stable: S, public readonly unstable?: U) { + if (!this.unstable && !this.stable) { + throw new Error("One of stable or unstable values must be supplied"); + } + } + + public get tsType(): U | S { + return null; // irrelevant return + } + + 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` as a default type for that namespace. + public findIn(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 extends NamespacedValue { + // 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; + } +} diff --git a/src/client.ts b/src/client.ts index e3acfeb9a..0d4fa7795 100644 --- a/src/client.ts +++ b/src/client.ts @@ -99,7 +99,13 @@ import { ISendEventResponse, IUploadOpts, } from "./@types/requests"; -import { EventType } from "./@types/event"; +import { + EventType, + RoomCreateTypeField, + RoomType, + UNSTABLE_MSC3088_ENABLED, + UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE +} from "./@types/event"; import { IImageInfo } from "./@types/partials"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import url from "url"; @@ -107,6 +113,7 @@ import { randomString } from "./randomstring"; import { ReadStream } from "fs"; import { WebStorageSessionStore } from "./store/session/webstorage"; import { BackupManager } from "./crypto/backup"; +import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; export type Store = StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; export type SessionStore = WebStorageSessionStore; @@ -7709,6 +7716,74 @@ export class MatrixClient extends EventEmitter { }); } + /** + * Creates a new file tree space with the given name. The client will pick + * defaults for how it expects to be able to support the remaining API offered + * by the returned class. + * + * Note that this is UNSTABLE and may have breaking changes without notice. + * @param {string} name The name of the tree space. + * @returns {Promise} Resolves to the created space. + */ + public async unstableCreateFileTree(name: string): Promise { + const { room_id: roomId } = await this.createRoom({ + name: name, + preset: "private_chat", + power_level_content_override: { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users: { + [this.getUserId()]: 100, + }, + }, + creation_content: { + [RoomCreateTypeField]: RoomType.Space, + }, + initial_state: [ + { + type: UNSTABLE_MSC3088_PURPOSE.name, + state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.name, + content: { + [UNSTABLE_MSC3088_ENABLED.name]: true, + }, + }, + { + type: EventType.RoomEncryption, + state_key: "", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + }, + }, + ], + }); + return new MSC3089TreeSpace(this, roomId); + } + + /** + * Gets a reference to a tree space, if the room ID given is a tree space. If the room + * does not appear to be a tree space then null is returned. + * + * Note that this is UNSTABLE and may have breaking changes without notice. + * @param {string} roomId The room ID to get a tree space reference for. + * @returns {MSC3089TreeSpace} The tree space, or null if not a tree space. + */ + public unstableGetFileTreeSpace(roomId: string): MSC3089TreeSpace { + const room = this.getRoom(roomId); + if (!room) return null; + + const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); + const purposeEvent = room.currentState.getStateEvents( + UNSTABLE_MSC3088_PURPOSE.name, + UNSTABLE_MSC3089_TREE_SUBTYPE.name); + + if (!createEvent || Array.isArray(createEvent)) throw new Error("Expected single room create event"); + if (!purposeEvent || Array.isArray(purposeEvent)) return null; + + if (!purposeEvent.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null; + if (createEvent.getContent()?.[RoomCreateTypeField] !== RoomType.Space) return null; + + return new MSC3089TreeSpace(this, roomId); + } + // TODO: Remove this warning, alongside the functions // See https://github.com/vector-im/element-web/issues/17532 // ====================================================== diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts new file mode 100644 index 000000000..8eb650bd4 --- /dev/null +++ b/src/models/MSC3089TreeSpace.ts @@ -0,0 +1,138 @@ +/* +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 } from "../@types/event"; +import { Room } from "./room"; + +/** + * 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); + } + + /** + * Syntactic sugar for room ID of the Space. + */ + public get id(): string { + return this.roomId; + } + + /** + * Sets the name of the tree space. + * @param {string} name The new name for the space. + * @returns {Promise} Resolves when complete. + */ + public setName(name: string): Promise { + 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} Resolves when complete. + */ + public invite(userId: string): Promise { + // 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} Resolves when complete. + */ + public async setPermissions(userId: string, role: TreePermissions): Promise { + 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, ""); + } +} From 36e2533164abb72c3c1dd150bbda51f7736b2070 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 9 Jun 2021 16:52:58 +0100 Subject: [PATCH 016/124] Add separate reason code for transferred calls 'Replaced' is special cased so the media isn't torn down, but we were passing Replaced for calls we transferred off elsewhere which meant we never closed the media capture. Calls transferred elsewhere aren't really replaced: they may as well have their own code. --- src/webrtc/call.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index f863b3bfb..009bce309 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -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 { @@ -1428,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); } /* @@ -1468,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) { From baaf76668f658ca94befe9c78cde63bca1bc590f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 9 Jun 2021 19:47:51 -0600 Subject: [PATCH 017/124] Early directory management --- spec/unit/models/MSC3089TreeSpace.spec.ts | 557 ++++++++++++++++++++++ spec/unit/utils.spec.js | 77 +++ src/models/MSC3089TreeSpace.ts | 243 ++++++++++ src/utils.ts | 105 ++++ 4 files changed, 982 insertions(+) diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index 4e3f069c2..ea6078a67 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -23,6 +23,7 @@ import { MSC3089TreeSpace, TreePermissions } from "../../../src/models/MSC3089TreeSpace"; +import { DEFAULT_ALPHABET } from "../../../src/utils"; describe("MSC3089TreeSpace", () => { let client: MatrixClient; @@ -178,4 +179,560 @@ describe("MSC3089TreeSpace", () => { 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; + 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; + (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); + }); + }); }); diff --git a/spec/unit/utils.spec.js b/spec/unit/utils.spec.js index 703326f46..3f461c714 100644 --- a/spec/unit/utils.spec.js +++ b/spec/unit/utils.spec.js @@ -1,4 +1,12 @@ import * as utils from "../../src/utils"; +import { + alphabetPad, + averageBetweenStrings, + baseToString, + DEFAULT_ALPHABET, + nextString, prevString, + stringToBase +} from "../../src/utils"; describe("utils", function() { describe("encodeParams", function() { @@ -259,4 +267,73 @@ describe("utils", function() { 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 defaultPrefixFor1char = [""].reduce(() => { + let s = ""; + for (let i = 0; i < DEFAULT_ALPHABET.length - 1; i++) { + s += DEFAULT_ALPHABET[0]; + } + return s; + }, ""); + expect(alphabetPad("a")).toEqual(defaultPrefixFor1char + "a"); + expect(alphabetPad("a", "123")).toEqual("11a"); + }); + }); + + describe('baseToString', () => { + it('should calculate the appropriate string from numbers', () => { + expect(baseToString(10)).toEqual(DEFAULT_ALPHABET[10]); + expect(baseToString(10, "abcdefghijklmnopqrstuvwxyz")).toEqual('k'); + expect(baseToString(6241)).toEqual("ab"); + expect(baseToString(53, "abcdefghijklmnopqrstuvwxyz")).toEqual('cb'); + }); + }); + + describe('stringToBase', () => { + it('should calculate the appropriate number for a string', () => { + expect(stringToBase(" ")).toEqual(0); + expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(0); + expect(stringToBase("a")).toEqual(65); + expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(2); + expect(stringToBase("ab")).toEqual(6241); + expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(53); + }); + }); + + describe('averageBetweenStrings', () => { + it('should average appropriately', () => { + 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("BH65B"); + expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("buedq"); + }); + }); + + 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'); + }); + }); }); diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index 8eb650bd4..b07fca706 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -17,6 +17,9 @@ limitations under the License. import { MatrixClient } from "../client"; import { EventType } from "../@types/event"; import { Room } from "./room"; +import { logger } from "../logger"; +import { MatrixEvent } from "./event"; +import { averageBetweenStrings, DEFAULT_ALPHABET, nextString, prevString } from "../utils"; /** * The recommended defaults for a tree space's power levels. Note that this @@ -47,6 +50,7 @@ export const DEFAULT_TREE_POWER_LEVELS_TEMPLATE = { [EventType.RoomMessageEncrypted]: 50, [EventType.Sticker]: 50, }, + users: {}, // defined by calling code }; @@ -70,6 +74,8 @@ export class MSC3089TreeSpace { public constructor(private client: MatrixClient, public readonly roomId: string) { this.room = this.client.getRoom(this.roomId); + + if (!this.room) throw new Error("Unknown room"); } /** @@ -79,6 +85,15 @@ export class MSC3089TreeSpace { return this.roomId; } + /** + * Whether or not this is a top level space. + */ + public get isTopLevel(): boolean { + 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. @@ -135,4 +150,232 @@ export class MSC3089TreeSpace { 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} Resolves to the created directory. + */ + public async createDirectory(name: string): Promise { + 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()); + 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} Resolves when complete. + */ + public async delete(): Promise { + 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) { + if (member.getStateKey() !== this.client.getUserId() && 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 a.roomId.localeCompare(b.roomId); + } + + const createTsA = roomA.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0; + const createTsB = roomB.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0; + if (createTsA === createTsB) { + return a.roomId.localeCompare(b.roomId); + } + return createTsA - createTsB; + } else { // both not-null orders + return a.order.localeCompare(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} Resolves when complete. + * @throws Throws if this is a top level space. + */ + public async setOrder(index: number): Promise { + 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, + order: newOrder, + }, this.roomId); + } } diff --git a/src/utils.ts b/src/utils.ts index a4a50153a..fd403a32e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -456,3 +456,108 @@ export function setCrypto(c: Object) { export function getCrypto(): Object { return crypto; } + +// String averaging based upon 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 = [""].reduce(() => { + 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 the + * same length as the alphabet, and padded with the first character in the alphabet. + * + * This is intended for use with string averaging. + * @param {string} s The string to pad. + * @param {string} alphabet The alphabet to use as a single string. + * @returns {string} The padded string. + */ +export function alphabetPad(s: string, alphabet = DEFAULT_ALPHABET): string { + while (s.length < alphabet.length) { + s = alphabet[0] + s; + } + return s; +} + +/** + * Converts a baseN number to a string, where N is the alphabet's length. + * + * This is intended for use with string averaging. + * @param {number} 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: number, alphabet = DEFAULT_ALPHABET): string { + const len = alphabet.length; + if (n < len) { + return alphabet[n]; + } + return baseToString(Math.floor(n / len), alphabet) + alphabet[n % len]; +} + +/** + * 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 {number} The baseN number. + */ +export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): number { + s = alphabetPad(s, alphabet); + const len = alphabet.length; + const reversedStr = Array.from(s).reverse(); + let result = 0; + for (let i = 0; i < len; i++) { + result += alphabet.indexOf(reversedStr[i]) * (len ** i); + } + 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 { + return baseToString(Math.floor((stringToBase(a, alphabet) + stringToBase(b, alphabet)) / 2), 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) + 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) - 1, alphabet); +} From b3a11030f2f859782173650ffa67f087e8801f3d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 9 Jun 2021 21:54:07 -0600 Subject: [PATCH 018/124] Early file management APIs --- spec/MockBlob.ts | 27 ++++ spec/unit/models/MSC3089Branch.spec.ts | 153 ++++++++++++++++++++++ spec/unit/models/MSC3089TreeSpace.spec.ts | 115 +++++++++++++++- src/@types/event.ts | 22 ++++ src/client.ts | 2 +- src/models/MSC3089Branch.ts | 102 +++++++++++++++ src/models/MSC3089TreeSpace.ts | 52 +++++++- 7 files changed, 467 insertions(+), 6 deletions(-) create mode 100644 spec/MockBlob.ts create mode 100644 spec/unit/models/MSC3089Branch.spec.ts create mode 100644 src/models/MSC3089Branch.ts diff --git a/spec/MockBlob.ts b/spec/MockBlob.ts new file mode 100644 index 000000000..04d01c24e --- /dev/null +++ b/spec/MockBlob.ts @@ -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[]) { + parts.forEach(p => Array.from(p).forEach(e => this.contents.push(e))); + } + + public get size(): number { + return this.contents.length; + } +} diff --git a/spec/unit/models/MSC3089Branch.spec.ts b/spec/unit/models/MSC3089Branch.spec.ts new file mode 100644 index 000000000..05c46653e --- /dev/null +++ b/spec/unit/models/MSC3089Branch.spec.ts @@ -0,0 +1,153 @@ +/* +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 = { + 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; // 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, '\\$&')}$`), + }); + }); +}); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index ea6078a67..3aebe5af1 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -17,13 +17,14 @@ limitations under the License. import { MatrixClient } from "../../../src"; import { Room } from "../../../src/models/room"; import { MatrixEvent } from "../../../src/models/event"; -import { EventType } from "../../../src/@types/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; @@ -37,8 +38,8 @@ describe("MSC3089TreeSpace", () => { beforeEach(() => { // TODO: Use utility functions to create test rooms and clients client = { - getRoom: (roomId: string) => { - if (roomId === roomId) { + getRoom: (fetchRoomId: string) => { + if (fetchRoomId === roomId) { return room; } else { throw new Error("Unexpected fetch for unknown room"); @@ -735,4 +736,112 @@ describe("MSC3089TreeSpace", () => { 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 + (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); + }); }); diff --git a/src/@types/event.ts b/src/@types/event.ts index 9e726de77..a4e11fe34 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -130,3 +130,25 @@ export const UNSTABLE_MSC3089_TREE_SUBTYPE = new UnstableValue("m.data_tree", "o * 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; +} diff --git a/src/client.ts b/src/client.ts index 0d4fa7795..65a42968d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4295,7 +4295,7 @@ export class MatrixClient extends EventEmitter { * {@link module:models/event-timeline~EventTimeline} including the given * event */ - public getEventTimeline(timelineSet: EventTimelineSet, eventId: string): EventTimeline { + public getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + diff --git a/src/models/MSC3089Branch.ts b/src/models/MSC3089Branch.ts new file mode 100644 index 000000000..655e374d8 --- /dev/null +++ b/src/models/MSC3089Branch.ts @@ -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} Resolves when complete. + */ + public async delete(): Promise { + 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} Resolves when complete. + */ + public setName(name: string): Promise { + 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 }; + } +} diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index b07fca706..153e4b494 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -15,11 +15,12 @@ limitations under the License. */ import { MatrixClient } from "../client"; -import { EventType } from "../@types/event"; +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, nextString, prevString } from "../utils"; +import { MSC3089Branch } from "./MSC3089Branch"; /** * The recommended defaults for a tree space's power levels. Note that this @@ -180,7 +181,7 @@ export class MSC3089TreeSpace { for (const child of children) { try { const tree = this.client.unstableGetFileTreeSpace(child.getStateKey()); - trees.push(tree); + if (tree) trees.push(tree); } catch (e) { logger.warn("Unable to create tree space instance for listing. Are we joined?", e); } @@ -378,4 +379,51 @@ export class MSC3089TreeSpace { 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} info The encrypted file information. + * @returns {Promise} Resolves when uploaded. + */ + public async createFile(name: string, encryptedContents: ArrayBuffer, info: Partial): Promise { + 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); + } } From bcccc909c5a28feb8a14793bdc495f9db7963a81 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 9 Jun 2021 21:55:06 -0600 Subject: [PATCH 019/124] Pre-lint format --- spec/unit/models/MSC3089Branch.spec.ts | 6 +-- spec/unit/models/MSC3089TreeSpace.spec.ts | 48 +++++++++++------------ src/models/MSC3089Branch.ts | 2 +- src/models/MSC3089TreeSpace.ts | 12 +++--- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/spec/unit/models/MSC3089Branch.spec.ts b/spec/unit/models/MSC3089Branch.spec.ts index 05c46653e..51b2577ec 100644 --- a/spec/unit/models/MSC3089Branch.spec.ts +++ b/spec/unit/models/MSC3089Branch.spec.ts @@ -128,10 +128,10 @@ describe("MSC3089Branch", () => { it('should be able to return event information', async () => { const mxcLatter = "example.org/file"; - const fileContent = {isFile: "not quite", url: "mxc://" + mxcLatter}; + const fileContent = { isFile: "not quite", url: "mxc://" + mxcLatter }; const eventsArr = [ - {getId: () => "$not-file", getContent: () => ({})}, - {getId: () => fileEventId, getContent: () => ({file: fileContent})}, + { getId: () => "$not-file", getContent: () => ({}) }, + { getId: () => fileEventId, getContent: () => ({ file: fileContent }) }, ]; client.getEventTimeline = () => Promise.resolve({ getEvents: () => eventsArr, diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index 3aebe5af1..cdac36c7b 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -21,7 +21,7 @@ import { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } fr import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace, - TreePermissions + TreePermissions, } from "../../../src/models/MSC3089TreeSpace"; import { DEFAULT_ALPHABET } from "../../../src/utils"; import { MockBlob } from "../../MockBlob"; @@ -87,7 +87,7 @@ describe("MSC3089TreeSpace", () => { expect(stateRoomId).toEqual(roomId); expect(eventType).toEqual(EventType.RoomName); expect(stateKey).toEqual(""); - expect(content).toMatchObject({name: newName}); + expect(content).toMatchObject({ name: newName }); return Promise.resolve(); }); client.sendStateEvent = fn; @@ -208,7 +208,7 @@ describe("MSC3089TreeSpace", () => { expect(eventType).toEqual(EventType.SpaceChild); expect(stateKey).toEqual(subspaceId); } - expect(content).toMatchObject({via: [domain]}); + expect(content).toMatchObject({ via: [domain] }); // return value not used }); @@ -223,7 +223,7 @@ describe("MSC3089TreeSpace", () => { expect(createFn).toHaveBeenCalledTimes(1); expect(sendStateFn).toHaveBeenCalledTimes(2); - const content = expect.objectContaining({via: [domain]}); + const content = expect.objectContaining({ via: [domain] }); expect(sendStateFn).toHaveBeenCalledWith(subspaceId, EventType.SpaceParent, content, tree.roomId); expect(sendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, content, subspaceId); }); @@ -238,9 +238,9 @@ describe("MSC3089TreeSpace", () => { expect(stateKey).toBeUndefined(); return [ // Partial implementations of Room - {getStateKey: () => firstChildRoom}, - {getStateKey: () => secondChildRoom}, - {getStateKey: () => thirdChildRoom}, + { getStateKey: () => firstChildRoom }, + { getStateKey: () => secondChildRoom }, + { getStateKey: () => thirdChildRoom }, ]; }, }; @@ -270,9 +270,9 @@ describe("MSC3089TreeSpace", () => { 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; + 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); @@ -284,10 +284,10 @@ describe("MSC3089TreeSpace", () => { 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 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 subdir2 = { delete: delete2 } as any as MSC3089TreeSpace; // mock tested bits const joinMemberId = "@join:example.org"; const knockMemberId = "@knock:example.org"; @@ -303,14 +303,14 @@ describe("MSC3089TreeSpace", () => { 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}, + { 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}, + { getContent: () => ({ membership: "join" }), getStateKey: () => selfUserId }, ] }, }; @@ -337,12 +337,12 @@ describe("MSC3089TreeSpace", () => { // 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[]} = {}; + 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 rooms: { [roomId: string]: Room }; let clientSendStateFn: jest.MockedFunction; const staticDomain = "static.example.org"; @@ -771,7 +771,7 @@ describe("MSC3089TreeSpace", () => { [UNSTABLE_MSC3089_LEAF.unstable]: {}, // test to ensure we're definitely using unstable }); - return Promise.resolve({event_id: fileEventId}); // eslint-disable-line camelcase + return Promise.resolve({ event_id: fileEventId }); // eslint-disable-line camelcase }); client.sendMessage = sendMsgFn; @@ -800,7 +800,7 @@ describe("MSC3089TreeSpace", () => { it('should support getting files', () => { const fileEventId = "$file"; - const fileEvent = {forTest: true}; // MatrixEvent mock + 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 @@ -829,8 +829,8 @@ describe("MSC3089TreeSpace", () => { }); it('should list files', () => { - const firstFile = {getContent: () => ({active: true})}; - const secondFile = {getContent: () => ({active: false})}; // deliberately inactive + 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 diff --git a/src/models/MSC3089Branch.ts b/src/models/MSC3089Branch.ts index 655e374d8..87acee0f8 100644 --- a/src/models/MSC3089Branch.ts +++ b/src/models/MSC3089Branch.ts @@ -92,7 +92,7 @@ export class MSC3089Branch { 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}); + await this.client.decryptEventIfNeeded(event, { emit: false, isRetry: false }); const file = event.getContent()['file']; const httpUrl = this.client.mxcUrlToHttp(file['url']); diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index 153e4b494..4b5656018 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -101,7 +101,7 @@ export class MSC3089TreeSpace { * @returns {Promise} Resolves when complete. */ public setName(name: string): Promise { - return this.client.sendStateEvent(this.roomId, EventType.RoomName, {name}, ""); + return this.client.sendStateEvent(this.roomId, EventType.RoomName, { name }, ""); } /** @@ -220,9 +220,9 @@ export class MSC3089TreeSpace { 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']})); + 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; @@ -357,7 +357,7 @@ export class MSC3089TreeSpace { // 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()]}; + const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] }; await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, { ...content, order: lastOrder, @@ -373,7 +373,7 @@ export class MSC3089TreeSpace { // 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()]}; + const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] }; await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, { ...content, order: newOrder, From 1f6ba31a3fe7ffd440afd981d488c736b234ec6c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 9 Jun 2021 21:56:46 -0600 Subject: [PATCH 020/124] Use a sane padStart instead --- src/utils.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index fd403a32e..dea8511c3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -484,10 +484,7 @@ export const DEFAULT_ALPHABET = [""].reduce(() => { * @returns {string} The padded string. */ export function alphabetPad(s: string, alphabet = DEFAULT_ALPHABET): string { - while (s.length < alphabet.length) { - s = alphabet[0] + s; - } - return s; + return s.padStart(alphabet.length, alphabet[0]); } /** From e41a2beb65d7fc0e4286715d4414ad31d6f1e1cf Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 9 Jun 2021 22:09:26 -0600 Subject: [PATCH 021/124] de-lint --- spec/unit/NamespacedValue.spec.ts | 2 +- spec/unit/matrix-client.spec.js | 13 ++- spec/unit/models/MSC3089Branch.spec.ts | 40 ++++--- spec/unit/models/MSC3089TreeSpace.spec.ts | 134 ++++++++++++---------- spec/unit/utils.spec.js | 9 +- src/@types/requests.ts | 6 +- src/client.ts | 3 +- 7 files changed, 112 insertions(+), 95 deletions(-) diff --git a/spec/unit/NamespacedValue.spec.ts b/spec/unit/NamespacedValue.spec.ts index 5b0d8e143..834acd0c9 100644 --- a/spec/unit/NamespacedValue.spec.ts +++ b/spec/unit/NamespacedValue.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {NamespacedValue, UnstableValue} from "../../src/NamespacedValue"; +import { NamespacedValue, UnstableValue } from "../../src/NamespacedValue"; describe("NamespacedValue", () => { it("should prefer stable over unstable", () => { diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.js index 80ff2e5a1..2523c98bd 100644 --- a/spec/unit/matrix-client.spec.js +++ b/spec/unit/matrix-client.spec.js @@ -1,16 +1,17 @@ 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 { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace"; import { EventType, - RoomCreateTypeField, RoomType, + RoomCreateTypeField, + RoomType, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, - UNSTABLE_MSC3089_TREE_SUBTYPE + UNSTABLE_MSC3089_TREE_SUBTYPE, } from "../../src/@types/event"; -import {MEGOLM_ALGORITHM} from "../../src/crypto/olmlib"; -import {MatrixEvent} from "../../src/models/event"; +import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib"; +import { MatrixEvent } from "../../src/models/event"; jest.useFakeTimers(); @@ -217,7 +218,7 @@ describe("MatrixClient", function() { }, ], }); - return {room_id: roomId}; + return { room_id: roomId }; }); client.getUserId = () => userId; client.createRoom = fn; diff --git a/spec/unit/models/MSC3089Branch.spec.ts b/spec/unit/models/MSC3089Branch.spec.ts index 51b2577ec..fc8b35815 100644 --- a/spec/unit/models/MSC3089Branch.spec.ts +++ b/spec/unit/models/MSC3089Branch.spec.ts @@ -70,15 +70,16 @@ describe("MSC3089Branch", () => { }); 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); + 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 - }); + return Promise.resolve(); // return value not used + }); client.sendStateEvent = stateFn; const redactFn = jest.fn().mockImplementation((roomId: string, eventId: string) => { @@ -107,18 +108,19 @@ describe("MSC3089Branch", () => { 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); + 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 - }); + return Promise.resolve(); // return value not used + }); client.sendStateEvent = stateFn; await branch.setName(name); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index cdac36c7b..a99140036 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -83,13 +83,14 @@ describe("MSC3089TreeSpace", () => { 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(); - }); + 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); @@ -109,18 +110,19 @@ describe("MSC3089TreeSpace", () => { 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, - }, + 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(); }); - return Promise.resolve(); - }); client.sendStateEvent = fn; await tree.setPermissions(targetUser, role); expect(fn.mock.calls.length).toBe(1); @@ -155,7 +157,7 @@ describe("MSC3089TreeSpace", () => { users_default: 1024, users: { [targetUser]: 2222, - } + }, }, TreePermissions.Viewer, 1024); }); @@ -165,7 +167,7 @@ describe("MSC3089TreeSpace", () => { events_default: 1024, users: { [targetUser]: 5, - } + }, }, TreePermissions.Editor, 1024); }); @@ -199,19 +201,20 @@ describe("MSC3089TreeSpace", () => { 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] }); + 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 - }); + // return value not used + }); client.unstableCreateFileTree = createFn; client.sendStateEvent = sendStateFn; @@ -311,7 +314,7 @@ describe("MSC3089TreeSpace", () => { // ensure we don't kick ourselves { getContent: () => ({ membership: "join" }), getStateKey: () => selfUserId }, - ] + ]; }, }; @@ -361,7 +364,7 @@ describe("MSC3089TreeSpace", () => { getType: () => EventType.SpaceParent, getStateKey: () => tree.roomId, getContent: () => ({ - via: [staticDomain] + via: [staticDomain], }), }, ]; @@ -408,7 +411,12 @@ describe("MSC3089TreeSpace", () => { roomId: tree.roomId, currentState: { getStateEvents: (eventType: EventType, stateKey?: string) => { - expect([EventType.SpaceChild, EventType.RoomCreate, EventType.SpaceParent]).toContain(eventType); + 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]; @@ -429,22 +437,23 @@ describe("MSC3089TreeSpace", () => { (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); + 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; + const stateEvent = parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey); + expect(stateEvent).toBeDefined(); + stateEvent.getContent = () => content; - return Promise.resolve(); // return value not used - }); + return Promise.resolve(); // return value not used + }); client.sendStateEvent = clientSendStateFn; }); @@ -652,7 +661,7 @@ describe("MSC3089TreeSpace", () => { const d = "!d:example.org"; // Add in reverse order to make sure it gets ordered correctly - addSubspace(d, 4, "Z") + addSubspace(d, 4, "Z"); addSubspace(c, 3, "X"); addSubspace(b, 2, "V"); addSubspace(a, 1, "T"); @@ -681,7 +690,7 @@ describe("MSC3089TreeSpace", () => { const d = "!d:example.org"; // Add in reverse order to make sure it gets ordered correctly - addSubspace(d, 4, "Z") + addSubspace(d, 4, "Z"); addSubspace(c, 3, "X"); addSubspace(b, 2, "V"); addSubspace(a, 1, "T"); @@ -710,7 +719,7 @@ describe("MSC3089TreeSpace", () => { const d = "!d:example.org"; // Add in reverse order to make sure it gets ordered correctly - addSubspace(d, 4) + addSubspace(d, 4); addSubspace(c, 3); addSubspace(b, 2, "V"); addSubspace(a, 1, "T"); @@ -775,17 +784,18 @@ describe("MSC3089TreeSpace", () => { }); 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, - }); + 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. - }); + return Promise.resolve(); // return value not used. + }); client.sendStateEvent = sendStateFn; const buf = Uint8Array.from(Array.from(fileContents).map((_, i) => fileContents.charCodeAt(i))); diff --git a/spec/unit/utils.spec.js b/spec/unit/utils.spec.js index 3f461c714..e922302cf 100644 --- a/spec/unit/utils.spec.js +++ b/spec/unit/utils.spec.js @@ -4,8 +4,9 @@ import { averageBetweenStrings, baseToString, DEFAULT_ALPHABET, - nextString, prevString, - stringToBase + nextString, + prevString, + stringToBase, } from "../../src/utils"; describe("utils", function() { @@ -270,7 +271,9 @@ describe("utils", function() { describe('DEFAULT_ALPHABET', () => { it('should be usefully printable ASCII in order', () => { - expect(DEFAULT_ALPHABET).toEqual(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"); + expect(DEFAULT_ALPHABET).toEqual( + " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~", + ); }); }); diff --git a/src/@types/requests.ts b/src/@types/requests.ts index b402bbdcb..a919727a9 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -74,9 +74,9 @@ export interface ICreateRoomOpts { name?: string; topic?: string; preset?: string; - power_level_content_override?: any; - creation_content?: any; - initial_state?: {type: string, state_key: string, content: any}[]; + power_level_content_override?: any;// eslint-disable-line camelcase + creation_content?: any;// eslint-disable-line camelcase + initial_state?: {type: string, state_key: string, content: any}[]; // eslint-disable-line camelcase // TODO: Types (next line) invite_3pid?: any[]; // eslint-disable-line camelcase } diff --git a/src/client.ts b/src/client.ts index 65a42968d..ad0f87510 100644 --- a/src/client.ts +++ b/src/client.ts @@ -104,7 +104,8 @@ import { RoomCreateTypeField, RoomType, UNSTABLE_MSC3088_ENABLED, - UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE + UNSTABLE_MSC3088_PURPOSE, + UNSTABLE_MSC3089_TREE_SUBTYPE, } from "./@types/event"; import { IImageInfo } from "./@types/partials"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; From 4eb44ee2ea59d130d01cdc778e6e9f504c66e55b Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 9 Jun 2021 22:14:11 -0600 Subject: [PATCH 022/124] de-lint 2 --- src/models/MSC3089TreeSpace.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index 4b5656018..74e6a7163 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -212,7 +212,8 @@ export class MSC3089TreeSpace { const kickMemberships = ["invite", "knock", "join"]; const members = this.room.currentState.getStateEvents(EventType.RoomMember); for (const member of members) { - if (member.getStateKey() !== this.client.getUserId() && kickMemberships.includes(member.getContent()['membership'])) { + const isNotUs = member.getStateKey() !== this.client.getUserId(); + if (isNotUs && kickMemberships.includes(member.getContent()['membership'])) { await this.client.kick(this.roomId, member.getStateKey(), "Room deleted"); } } @@ -387,7 +388,10 @@ export class MSC3089TreeSpace { * @param {Partial} info The encrypted file information. * @returns {Promise} Resolves when uploaded. */ - public async createFile(name: string, encryptedContents: ArrayBuffer, info: Partial): Promise { + public async createFile( + name: string, + encryptedContents: ArrayBuffer, info: Partial, + ): Promise { const mxc = await this.client.uploadContent(new Blob([encryptedContents]), { includeFilename: false, onlyContentUri: true, From f535e7535ce8a0a1df0026dc016654228680af5d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 10 Jun 2021 15:37:00 -0600 Subject: [PATCH 023/124] Update string averaging utils --- spec/unit/utils.spec.js | 65 ++++++++++++++++++++++++++-------- src/client.ts | 4 +-- src/models/MSC3089TreeSpace.ts | 8 ++--- src/utils.ts | 35 +++++++++++++----- 4 files changed, 83 insertions(+), 29 deletions(-) diff --git a/spec/unit/utils.spec.js b/spec/unit/utils.spec.js index e922302cf..84921a24c 100644 --- a/spec/unit/utils.spec.js +++ b/spec/unit/utils.spec.js @@ -4,6 +4,7 @@ import { averageBetweenStrings, baseToString, DEFAULT_ALPHABET, + lexicographicCompare, nextString, prevString, stringToBase, @@ -279,15 +280,9 @@ describe("utils", function() { describe('alphabetPad', () => { it('should pad to the alphabet length', () => { - const defaultPrefixFor1char = [""].reduce(() => { - let s = ""; - for (let i = 0; i < DEFAULT_ALPHABET.length - 1; i++) { - s += DEFAULT_ALPHABET[0]; - } - return s; - }, ""); - expect(alphabetPad("a")).toEqual(defaultPrefixFor1char + "a"); - expect(alphabetPad("a", "123")).toEqual("11a"); + 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'))); }); }); @@ -313,12 +308,12 @@ describe("utils", function() { describe('averageBetweenStrings', () => { it('should average appropriately', () => { - expect(averageBetweenStrings('A', 'z')).toEqual(']'); - expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('m'); + expect(averageBetweenStrings('A', 'z')).toEqual('^'); + expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('n'); expect(averageBetweenStrings('AA', 'zz')).toEqual('^.'); - expect(averageBetweenStrings('aa', 'zz', "abcdefghijklmnopqrstuvwxyz")).toEqual('mz'); - expect(averageBetweenStrings('cat', 'doggo')).toEqual("BH65B"); - expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("buedq"); + expect(averageBetweenStrings('aa', 'zz', "abcdefghijklmnopqrstuvwxyz")).toEqual('na'); + expect(averageBetweenStrings('cat', 'doggo')).toEqual("d9>Cw"); + expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("cumqh"); }); }); @@ -339,4 +334,46 @@ describe("utils", function() { 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); + console.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; + } + }); + }); + + 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); + }); + }); }); diff --git a/src/client.ts b/src/client.ts index ad0f87510..8015b2ad5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7776,8 +7776,8 @@ export class MatrixClient extends EventEmitter { UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name); - if (!createEvent || Array.isArray(createEvent)) throw new Error("Expected single room create event"); - if (!purposeEvent || Array.isArray(purposeEvent)) return null; + if (!createEvent) throw new Error("Expected single room create event"); + if (!purposeEvent) return null; if (!purposeEvent.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null; if (createEvent.getContent()?.[RoomCreateTypeField] !== RoomType.Space) return null; diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index 74e6a7163..b7849392e 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -19,7 +19,7 @@ import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_M import { Room } from "./room"; import { logger } from "../logger"; import { MatrixEvent } from "./event"; -import { averageBetweenStrings, DEFAULT_ALPHABET, nextString, prevString } from "../utils"; +import { averageBetweenStrings, DEFAULT_ALPHABET, lexicographicCompare, nextString, prevString } from "../utils"; import { MSC3089Branch } from "./MSC3089Branch"; /** @@ -233,17 +233,17 @@ export class MSC3089TreeSpace { 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 a.roomId.localeCompare(b.roomId); + 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 a.roomId.localeCompare(b.roomId); + return lexicographicCompare(a.roomId, b.roomId); } return createTsA - createTsB; } else { // both not-null orders - return a.order.localeCompare(b.order); + return lexicographicCompare(a.order, b.order); } }); return ordered; diff --git a/src/utils.ts b/src/utils.ts index dea8511c3..326fe359c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -475,16 +475,17 @@ export const DEFAULT_ALPHABET = [""].reduce(() => { }, ""); /** - * Pads a string using the given alphabet as a base. The returned string will be the - * same length as the alphabet, and padded with the first character in the alphabet. + * 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, alphabet = DEFAULT_ALPHABET): string { - return s.padStart(alphabet.length, alphabet[0]); +export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): string { + return s.padEnd(n, alphabet[0]); } /** @@ -512,12 +513,12 @@ export function baseToString(n: number, alphabet = DEFAULT_ALPHABET): string { * @returns {number} The baseN number. */ export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): number { - s = alphabetPad(s, alphabet); const len = alphabet.length; - const reversedStr = Array.from(s).reverse(); + const reversedStr = Array.from(s).reverse().join(""); // keep as string let result = 0; - for (let i = 0; i < len; i++) { - result += alphabet.indexOf(reversedStr[i]) * (len ** i); + for (let i = 0; i < reversedStr.length; i++) { + // Cost effective version of `result += alphabet.indexOf(reversedStr[i]) * (len ** i);` + result += (reversedStr.charCodeAt(i) - alphabet.charCodeAt(0)) * (len ** i); } return result; } @@ -532,7 +533,10 @@ export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): number { * @returns {string} The midpoint between the strings, as a string. */ export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_ALPHABET): string { - return baseToString(Math.floor((stringToBase(a, alphabet) + stringToBase(b, alphabet)) / 2), alphabet); + const padN = Math.max(a.length, b.length); + const baseA = stringToBase(alphabetPad(a, padN, alphabet), alphabet); + const baseB = stringToBase(alphabetPad(b, padN, alphabet), alphabet); + return baseToString(Math.round((baseA + baseB) / 2), alphabet); } /** @@ -558,3 +562,16 @@ export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string { export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string { return baseToString(stringToBase(s, alphabet) - 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); +} From f1e270ca9d6adde87884df5f7dd20da3eee48ac1 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 10 Jun 2021 15:54:56 -0600 Subject: [PATCH 024/124] appease the linter --- spec/unit/utils.spec.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/unit/utils.spec.js b/spec/unit/utils.spec.js index 84921a24c..0d1af3caf 100644 --- a/spec/unit/utils.spec.js +++ b/spec/unit/utils.spec.js @@ -9,6 +9,7 @@ import { prevString, stringToBase, } from "../../src/utils"; +import { logger } from "../../src/logger"; describe("utils", function() { describe("encodeParams", function() { @@ -344,7 +345,7 @@ describe("utils", function() { for (let i = 0; i < 100; i++) { const next = nextString(midpoint); const prev = prevString(midpoint); - console.log({i, midpoint, next, prev}); // for test debugging + logger.log({ i, midpoint, next, prev }); // for test debugging expect(lexicographicCompare(midpoint, next) < 0).toBe(true); expect(lexicographicCompare(midpoint, prev) > 0).toBe(true); From 5715df6b180de4c4eb10c0e5a299aa1a1688054b Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Jun 2021 11:18:18 -0600 Subject: [PATCH 025/124] BigInt, rollover, and developer lint --- spec/unit/utils.spec.js | 73 ++++++++++++++++++++++++++++------ src/@types/requests.ts | 13 +++--- src/NamespacedValue.ts | 8 ++-- src/client.ts | 3 +- src/models/MSC3089TreeSpace.ts | 4 ++ src/utils.ts | 45 +++++++++++++-------- 6 files changed, 104 insertions(+), 42 deletions(-) diff --git a/spec/unit/utils.spec.js b/spec/unit/utils.spec.js index 0d1af3caf..009a19852 100644 --- a/spec/unit/utils.spec.js +++ b/spec/unit/utils.spec.js @@ -289,30 +289,30 @@ describe("utils", function() { describe('baseToString', () => { it('should calculate the appropriate string from numbers', () => { - expect(baseToString(10)).toEqual(DEFAULT_ALPHABET[10]); - expect(baseToString(10, "abcdefghijklmnopqrstuvwxyz")).toEqual('k'); - expect(baseToString(6241)).toEqual("ab"); - expect(baseToString(53, "abcdefghijklmnopqrstuvwxyz")).toEqual('cb'); + expect(baseToString(BigInt(10))).toEqual(DEFAULT_ALPHABET[10]); + expect(baseToString(BigInt(10), "abcdefghijklmnopqrstuvwxyz")).toEqual('k'); + expect(baseToString(BigInt(6241))).toEqual("ab"); + expect(baseToString(BigInt(53), "abcdefghijklmnopqrstuvwxyz")).toEqual('cb'); }); }); describe('stringToBase', () => { it('should calculate the appropriate number for a string', () => { - expect(stringToBase(" ")).toEqual(0); - expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(0); - expect(stringToBase("a")).toEqual(65); - expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(2); - expect(stringToBase("ab")).toEqual(6241); - expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(53); + expect(stringToBase(" ")).toEqual(BigInt(0)); + expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(0)); + expect(stringToBase("a")).toEqual(BigInt(65)); + expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(2)); + expect(stringToBase("ab")).toEqual(BigInt(6241)); + expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(53)); }); }); describe('averageBetweenStrings', () => { it('should average appropriately', () => { - expect(averageBetweenStrings('A', 'z')).toEqual('^'); - expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('n'); + expect(averageBetweenStrings('A', 'z')).toEqual(']'); + expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('m'); expect(averageBetweenStrings('AA', 'zz')).toEqual('^.'); - expect(averageBetweenStrings('aa', 'zz', "abcdefghijklmnopqrstuvwxyz")).toEqual('na'); + expect(averageBetweenStrings('aa', 'zz', "abcdefghijklmnopqrstuvwxyz")).toEqual('mz'); expect(averageBetweenStrings('cat', 'doggo')).toEqual("d9>Cw"); expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("cumqh"); }); @@ -354,6 +354,53 @@ describe("utils", function() { midpoint = next; } }); + + it('should roll over', () => { + const lastAlpha = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1]; + const firstAlpha = DEFAULT_ALPHABET[0]; + const secondAlpha = DEFAULT_ALPHABET[1]; + + // You may be looking at this and wondering how on this planet we end up with + // the next string being +2 on the ASCII table, and this comment is here to tell + // you that you're not insane. Due to a property of the baseN conversion, our + // +1 (and -1 for prevString) turns into +2 because the first character in the + // alphabet is equivalent to zero rather than one. Thus, we're actually adding + // the second character of the alphabet (due to adding a numeric 1) to the + // input string, thus resulting in a human-understandable +2 jump rather than + // a +1 one. + + // Let's validate that +1 behaviour with math + expect(stringToBase(DEFAULT_ALPHABET[0])).toEqual(BigInt(0)); + expect(stringToBase(DEFAULT_ALPHABET[1])).toEqual(BigInt(1)); + + const highRoll = secondAlpha + 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. + + // See rollover test for why we use weird parts of the alphabet + const maxSpaceValue = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1].repeat(50); + const fiftyFirstChar = DEFAULT_ALPHABET[1] + DEFAULT_ALPHABET[0].repeat(50); + + 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', () => { diff --git a/src/@types/requests.ts b/src/@types/requests.ts index a919727a9..a149875d8 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -68,18 +68,21 @@ export interface IEventSearchOpts { term: string; } +// allow camelcase as these are things go onto the wire +/* eslint-disable camelcase */ export interface ICreateRoomOpts { - room_alias_name?: string; // eslint-disable-line camelcase + room_alias_name?: string; visibility?: "public" | "private"; name?: string; topic?: string; preset?: string; - power_level_content_override?: any;// eslint-disable-line camelcase - creation_content?: any;// eslint-disable-line camelcase - initial_state?: {type: string, state_key: string, content: any}[]; // eslint-disable-line camelcase + power_level_content_override?: any; + creation_content?: any; + initial_state?: {type: string, state_key: string, content: any}[]; // TODO: Types (next line) - invite_3pid?: any[]; // eslint-disable-line camelcase + invite_3pid?: any[]; } +/* eslint-enable camelcase */ export interface IRoomDirectoryOptions { server?: string; diff --git a/src/NamespacedValue.ts b/src/NamespacedValue.ts index e20beab2d..d493f38aa 100644 --- a/src/NamespacedValue.ts +++ b/src/NamespacedValue.ts @@ -19,16 +19,14 @@ limitations under the License. * is provided that the stable prefix should be used when representing the identifier. */ export class NamespacedValue { - public constructor(public readonly stable: S, public readonly unstable?: U) { + // 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 tsType(): U | S { - return null; // irrelevant return - } - public get name(): U | S { if (this.stable) { return this.stable; diff --git a/src/client.ts b/src/client.ts index 8015b2ad5..1e494a93a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7777,9 +7777,8 @@ export class MatrixClient extends EventEmitter { UNSTABLE_MSC3089_TREE_SUBTYPE.name); if (!createEvent) throw new Error("Expected single room create event"); - if (!purposeEvent) return null; - if (!purposeEvent.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null; + if (!purposeEvent?.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null; if (createEvent.getContent()?.[RoomCreateTypeField] !== RoomType.Space) return null; return new MSC3089TreeSpace(this, roomId); diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index b7849392e..f36642a8f 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -90,6 +90,8 @@ export class MSC3089TreeSpace { * 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']); @@ -377,6 +379,8 @@ export class MSC3089TreeSpace { 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); } diff --git a/src/utils.ts b/src/utils.ts index 326fe359c..eac66aa00 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -457,7 +457,7 @@ export function getCrypto(): Object { return crypto; } -// String averaging based upon https://stackoverflow.com/a/2510816 +// 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. @@ -492,16 +492,17 @@ export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): * Converts a baseN number to a string, where N is the alphabet's length. * * This is intended for use with string averaging. - * @param {number} n The baseN number. + * @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: number, alphabet = DEFAULT_ALPHABET): string { - const len = alphabet.length; +export function baseToString(n: bigint, alphabet = DEFAULT_ALPHABET): string { + const len = BigInt(alphabet.length); if (n < len) { - return alphabet[n]; + return alphabet[Number(n)]; } - return baseToString(Math.floor(n / len), alphabet) + alphabet[n % len]; + + return baseToString(n / len, alphabet) + alphabet[Number(n % len)]; } /** @@ -510,15 +511,25 @@ export function baseToString(n: number, alphabet = DEFAULT_ALPHABET): string { * 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 {number} The baseN number. + * @returns {bigint} The baseN number. */ -export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): number { - const len = alphabet.length; - const reversedStr = Array.from(s).reverse().join(""); // keep as string - let result = 0; - for (let i = 0; i < reversedStr.length; i++) { - // Cost effective version of `result += alphabet.indexOf(reversedStr[i]) * (len ** i);` - result += (reversedStr.charCodeAt(i) - alphabet.charCodeAt(0)) * (len ** i); +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++) { + result += BigInt(s.charCodeAt(i) - alphabet.charCodeAt(0)) * (len ** j); } return result; } @@ -536,7 +547,7 @@ export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_A const padN = Math.max(a.length, b.length); const baseA = stringToBase(alphabetPad(a, padN, alphabet), alphabet); const baseB = stringToBase(alphabetPad(b, padN, alphabet), alphabet); - return baseToString(Math.round((baseA + baseB) / 2), alphabet); + return baseToString((baseA + baseB) / BigInt(2), alphabet); } /** @@ -548,7 +559,7 @@ export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_A * @returns {string} The string which follows the input string. */ export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string { - return baseToString(stringToBase(s, alphabet) + 1, alphabet); + return baseToString(stringToBase(s, alphabet) + BigInt(1), alphabet); } /** @@ -560,7 +571,7 @@ export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string { * @returns {string} The string which precedes the input string. */ export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string { - return baseToString(stringToBase(s, alphabet) - 1, alphabet); + return baseToString(stringToBase(s, alphabet) - BigInt(1), alphabet); } /** From 4b19b36de1039c50d00393ba1099027b21bc52a4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Jun 2021 11:27:46 -0600 Subject: [PATCH 026/124] Crude JS->TS conversion on utils test because of linter and BigInt --- spec/unit/{utils.spec.js => utils.spec.ts} | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) rename spec/unit/{utils.spec.js => utils.spec.ts} (99%) diff --git a/spec/unit/utils.spec.js b/spec/unit/utils.spec.ts similarity index 99% rename from spec/unit/utils.spec.js rename to spec/unit/utils.spec.ts index 009a19852..e23d0ca93 100644 --- a/spec/unit/utils.spec.js +++ b/spec/unit/utils.spec.ts @@ -11,6 +11,8 @@ import { } 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() { @@ -238,7 +240,8 @@ describe("utils", function() { }, }); - const target = {}; + // TODO: Fix type + const target: any = {}; utils.extend(target, source); expect(target.enumerableProp).toBe(true); expect(target.nonenumerableProp).toBe(undefined); @@ -254,7 +257,7 @@ describe("utils", function() { await utils.sleep(1); expect(promiseCount).toEqual(0); ++promiseCount; - resolve(); + resolve(null); }); } @@ -262,7 +265,7 @@ describe("utils", function() { return new Promise(function(resolve, reject) { expect(promiseCount).toEqual(1); ++promiseCount; - resolve(); + resolve(null); }); } From 63fa774af7873d125f3103f1bdcc008990c82c1b Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Jun 2021 11:34:37 -0600 Subject: [PATCH 027/124] Another round of appeasement --- spec/unit/utils.spec.ts | 44 ++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index e23d0ca93..4a1a60f8e 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -70,7 +70,7 @@ describe("utils", 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(undefined)).toBe(false); expect(utils.isFunction(null)).toBe(false); expect(utils.isFunction({})).toBe(false); expect(utils.isFunction("foo")).toBe(false); @@ -98,16 +98,11 @@ describe("utils", function() { describe("checkObjectHasNoAdditionalKeys", function() { it("should throw for extra keys", function() { expect(function() { - utils.checkObjectHasNoAdditionalKeys({ - foo: "bar", - baz: 4, - }, ["foo"]); + utils.checkObjectHasNoAdditionalKeys({ foo: "bar", baz: 4 }, ["foo"]); }).toThrow(); expect(function() { - utils.checkObjectHasNoAdditionalKeys({ - foo: "bar", - }, ["foo"]); + utils.checkObjectHasNoAdditionalKeys({ foo: "bar" }, ["foo"]); }).not.toThrow(); }); }); @@ -138,10 +133,8 @@ describe("utils", function() { }); 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"))); + 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() { @@ -157,15 +150,21 @@ describe("utils", function() { 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.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({ + 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)); @@ -253,12 +252,11 @@ describe("utils", function() { let promiseCount = 0; function fn1() { - return new Promise(async function(resolve, reject) { + return (async function() { await utils.sleep(1); expect(promiseCount).toEqual(0); ++promiseCount; - resolve(null); - }); + })(); } function fn2() { From 0541b7f3c5d023995554633bc5ea69cf199eebad Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Jun 2021 11:36:33 -0600 Subject: [PATCH 028/124] Remove a layer of indirection --- spec/unit/utils.spec.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index 4a1a60f8e..cd0f43823 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -251,20 +251,15 @@ describe("utils", function() { it("should execute promises in chunks", async function() { let promiseCount = 0; - function fn1() { - return (async function() { - await utils.sleep(1); - expect(promiseCount).toEqual(0); - ++promiseCount; - })(); + async function fn1() { + await utils.sleep(1); + expect(promiseCount).toEqual(0); + ++promiseCount; } - function fn2() { - return new Promise(function(resolve, reject) { - expect(promiseCount).toEqual(1); - ++promiseCount; - resolve(null); - }); + async function fn2() { + expect(promiseCount).toEqual(1); + ++promiseCount; } await utils.chunkPromises([fn1, fn2], 1); From 9af214007e57cacc1010b9c7cdd1fcaf36044cba Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 11 Jun 2021 11:37:44 -0600 Subject: [PATCH 029/124] APPEASE. THE. LINTER. --- spec/unit/utils.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index cd0f43823..ba5fb791f 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -152,10 +152,10 @@ describe("utils", function() { assert.isTrue(utils.deepCompare({ 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 26 } + 2: { name: "arb", age: 26 }, }, { 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 26 } + 2: { name: "arb", age: 26 }, })); assert.isFalse(utils.deepCompare({ From d99ea1c6b4f22c3cb9bbfc7a09cf3bb00edf740e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 14 Jun 2021 08:21:09 -0600 Subject: [PATCH 030/124] Update src/utils.ts Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index eac66aa00..ce6b1f576 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -466,13 +466,13 @@ export function getCrypto(): Object { * The default alphabet used by string averaging in this SDK. This matches * all usefully printable ASCII characters (0x20-0x7E, inclusive). */ -export const DEFAULT_ALPHABET = [""].reduce(() => { +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 From d3027e1fe879bd63b8cb3960547d651c9f0ce019 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 14 Jun 2021 10:28:26 -0600 Subject: [PATCH 031/124] Offset the alphabet by 1 --- spec/unit/utils.spec.ts | 62 +++++++++++++++++++++++------------------ src/utils.ts | 30 +++++++++++++++++--- 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index ba5fb791f..05c768e1e 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -285,26 +285,49 @@ describe("utils", function() { describe('baseToString', () => { it('should calculate the appropriate string from numbers', () => { - expect(baseToString(BigInt(10))).toEqual(DEFAULT_ALPHABET[10]); - expect(baseToString(BigInt(10), "abcdefghijklmnopqrstuvwxyz")).toEqual('k'); - expect(baseToString(BigInt(6241))).toEqual("ab"); - expect(baseToString(BigInt(53), "abcdefghijklmnopqrstuvwxyz")).toEqual('cb'); + // Verify the whole alphabet + for (let i = BigInt(1); i <= DEFAULT_ALPHABET.length; i++) { + console.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(" ")).toEqual(BigInt(0)); - expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(0)); - expect(stringToBase("a")).toEqual(BigInt(65)); - expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(2)); - expect(stringToBase("ab")).toEqual(BigInt(6241)); - expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(53)); + 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', () => { + console.log(stringToBase(" ")); + console.log(stringToBase("!!")); + expect(averageBetweenStrings(" ", "!!")).toEqual(" P"); expect(averageBetweenStrings('A', 'z')).toEqual(']'); expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('m'); expect(averageBetweenStrings('AA', 'zz')).toEqual('^.'); @@ -354,22 +377,8 @@ describe("utils", function() { it('should roll over', () => { const lastAlpha = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1]; const firstAlpha = DEFAULT_ALPHABET[0]; - const secondAlpha = DEFAULT_ALPHABET[1]; - // You may be looking at this and wondering how on this planet we end up with - // the next string being +2 on the ASCII table, and this comment is here to tell - // you that you're not insane. Due to a property of the baseN conversion, our - // +1 (and -1 for prevString) turns into +2 because the first character in the - // alphabet is equivalent to zero rather than one. Thus, we're actually adding - // the second character of the alphabet (due to adding a numeric 1) to the - // input string, thus resulting in a human-understandable +2 jump rather than - // a +1 one. - - // Let's validate that +1 behaviour with math - expect(stringToBase(DEFAULT_ALPHABET[0])).toEqual(BigInt(0)); - expect(stringToBase(DEFAULT_ALPHABET[1])).toEqual(BigInt(1)); - - const highRoll = secondAlpha + firstAlpha; + const highRoll = firstAlpha + firstAlpha; const lowRoll = lastAlpha; expect(nextString(lowRoll)).toEqual(highRoll); @@ -386,9 +395,8 @@ describe("utils", function() { it('should properly handle rolling over at 50 characters', () => { // Note: we also test reversibility of large strings here. - // See rollover test for why we use weird parts of the alphabet const maxSpaceValue = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1].repeat(50); - const fiftyFirstChar = DEFAULT_ALPHABET[1] + DEFAULT_ALPHABET[0].repeat(50); + const fiftyFirstChar = DEFAULT_ALPHABET[0].repeat(51); expect(nextString(maxSpaceValue)).toBe(fiftyFirstChar); expect(prevString(fiftyFirstChar)).toBe(maxSpaceValue); diff --git a/src/utils.ts b/src/utils.ts index ce6b1f576..60dec0999 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -497,12 +497,29 @@ export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): * @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)]; + if (n <= len) { + return alphabet[Number(n) - 1]; } - return baseToString(n / len, alphabet) + alphabet[Number(n % len)]; + 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]; } /** @@ -527,9 +544,14 @@ export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint { // 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++) { - result += BigInt(s.charCodeAt(i) - alphabet.charCodeAt(0)) * (len ** 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; } From cfef635e1be3523a8484c7fc750592fa5a8c2cf5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 14 Jun 2021 10:41:32 -0600 Subject: [PATCH 032/124] Appease the linter --- spec/unit/utils.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index 05c768e1e..c03a29909 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -287,7 +287,7 @@ describe("utils", function() { it('should calculate the appropriate string from numbers', () => { // Verify the whole alphabet for (let i = BigInt(1); i <= DEFAULT_ALPHABET.length; i++) { - console.log({i}); // for debugging + logger.log({ i }); // for debugging expect(baseToString(i)).toEqual(DEFAULT_ALPHABET[Number(i) - 1]); } @@ -325,8 +325,8 @@ describe("utils", function() { describe('averageBetweenStrings', () => { it('should average appropriately', () => { - console.log(stringToBase(" ")); - console.log(stringToBase("!!")); + logger.log(stringToBase(" ")); + logger.log(stringToBase("!!")); expect(averageBetweenStrings(" ", "!!")).toEqual(" P"); expect(averageBetweenStrings('A', 'z')).toEqual(']'); expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('m'); From 991a25504169e07d06908b813c1963e4556d812a Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 14 Jun 2021 11:55:41 -0600 Subject: [PATCH 033/124] Fix average on .5 --- spec/unit/utils.spec.ts | 5 +++-- src/utils.ts | 12 ++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index c03a29909..76123d1ca 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -325,9 +325,10 @@ describe("utils", function() { describe('averageBetweenStrings', () => { it('should average appropriately', () => { - logger.log(stringToBase(" ")); - logger.log(stringToBase("!!")); 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('^.'); diff --git a/src/utils.ts b/src/utils.ts index 60dec0999..d6b294719 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -568,8 +568,16 @@ export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint { 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); - return baseToString((baseA + baseB) / BigInt(2), alphabet); + let baseB = stringToBase(alphabetPad(b, padN, alphabet), alphabet); + let 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); } /** From 4488f174aa44209fbb6cac5e2408a88d9d1936b5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 14 Jun 2021 12:00:59 -0600 Subject: [PATCH 034/124] const --- src/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index d6b294719..e50c512b0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -568,8 +568,8 @@ export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint { 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); - let baseB = stringToBase(alphabetPad(b, padN, alphabet), alphabet); - let avg = (baseA + baseB) / BigInt(2); + 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. From 3c85bcc3c91ff1370e6e62ffc7cf188a031b7d36 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 14 Jun 2021 13:32:28 -0600 Subject: [PATCH 035/124] Move various types from the react-sdk to the js-sdk --- src/@types/partials.ts | 11 +++++++++++ src/@types/requests.ts | 30 +++++++++++++++++++++++------- src/client.ts | 14 ++++++++++---- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/@types/partials.ts b/src/@types/partials.ts index 4daa935d0..771c60b47 100644 --- a/src/@types/partials.ts +++ b/src/@types/partials.ts @@ -26,3 +26,14 @@ export interface IImageInfo { w?: number; h?: number; } + +export enum Visibility { + Public = "public", + Private = "private", +} + +export enum Preset { + PrivateChat = "private_chat", + TrustedPrivateChat = "trusted_private_chat", + PublicChat = "public_chat", +} diff --git a/src/@types/requests.ts b/src/@types/requests.ts index a149875d8..5607cea59 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { Callback } from "../client"; +import { Preset, Visibility } from "./partials"; export interface IJoinRoomOpts { /** @@ -68,19 +69,34 @@ export interface IEventSearchOpts { 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; +} + // allow camelcase as these are things go onto the wire /* eslint-disable camelcase */ export interface ICreateRoomOpts { room_alias_name?: string; - visibility?: "public" | "private"; + visibility?: Visibility; name?: string; topic?: string; - preset?: string; - power_level_content_override?: any; - creation_content?: any; - initial_state?: {type: string, state_key: string, content: any}[]; - // TODO: Types (next line) - invite_3pid?: any[]; + preset?: Preset; + power_level_content_override?: object; + creation_content?: object; + initial_state?: ICreateRoomStateEvent[]; + invite?: string[]; + invite_3pid?: IInvite3PID[]; + is_direct?: boolean; + room_version?: string; } /* eslint-enable camelcase */ diff --git a/src/client.ts b/src/client.ts index 1e494a93a..90e57f2b1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -107,7 +107,7 @@ import { UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "./@types/event"; -import { IImageInfo } from "./@types/partials"; +import { IImageInfo, Preset } from "./@types/partials"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import url from "url"; import { randomString } from "./randomstring"; @@ -5585,15 +5585,21 @@ export class MatrixClient extends EventEmitter { /** * Query the server to see if it is forcing encryption to be enabled for * a given room preset, based on the /versions response. - * @param {string} presetName The name of the preset to check. + * @param {Preset} presetName The name of the preset to check. * @returns {Promise} true if the server is forcing encryption * for the preset. */ - public async doesServerForceEncryptionForPreset(presetName: string): Promise { + public async doesServerForceEncryptionForPreset(presetName: Preset): Promise { const response = await this.getVersions(); if (!response) return false; const unstableFeatures = response["unstable_features"]; - return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${presetName}`]; + + // The preset name in the versions response will be without the _chat suffix. + const versionsPresetName = presetName.includes("_chat") + ? presetName.substring(0, presetName.indexOf("_chat")) + : presetName; + + return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${versionsPresetName}`]; } /** From 6840ee077ced1e495b0901e75c2766a870c183bc Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 14 Jun 2021 13:34:37 -0600 Subject: [PATCH 036/124] Appease the linter forever --- src/@types/requests.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/@types/requests.ts b/src/@types/requests.ts index 5607cea59..eaf682831 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -17,6 +17,9 @@ 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 @@ -41,12 +44,12 @@ export interface IRedactOpts { } export interface ISendEventResponse { - event_id: string; // eslint-disable-line camelcase + event_id: string; } export interface IPresenceOpts { presence: "online" | "offline" | "unavailable"; - status_msg?: string; // eslint-disable-line camelcase + status_msg?: string; } export interface IPaginateOpts { @@ -82,8 +85,6 @@ export interface ICreateRoomStateEvent { content: object; } -// allow camelcase as these are things go onto the wire -/* eslint-disable camelcase */ export interface ICreateRoomOpts { room_alias_name?: string; visibility?: Visibility; @@ -98,7 +99,6 @@ export interface ICreateRoomOpts { is_direct?: boolean; room_version?: string; } -/* eslint-enable camelcase */ export interface IRoomDirectoryOptions { server?: string; @@ -106,7 +106,7 @@ export interface IRoomDirectoryOptions { since?: string; // TODO: Proper types - filter?: any & {generic_search_term: string}; // eslint-disable-line camelcase + filter?: any & {generic_search_term: string}; } export interface IUploadOpts { @@ -118,3 +118,5 @@ export interface IUploadOpts { callback?: Callback; progressHandler?: (state: {loaded: number, total: number}) => void; } + +/* eslint-enable camelcase */ From 6db7972f040b5dc71f825e3b68ebdfe211620c52 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 14 Jun 2021 13:35:20 -0600 Subject: [PATCH 037/124] preset --- spec/unit/matrix-client.spec.js | 3 ++- src/client.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.js index 2523c98bd..02017a4c3 100644 --- a/spec/unit/matrix-client.spec.js +++ b/spec/unit/matrix-client.spec.js @@ -12,6 +12,7 @@ import { } 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(); @@ -190,7 +191,7 @@ describe("MatrixClient", function() { const fn = jest.fn().mockImplementation((opts) => { expect(opts).toMatchObject({ name: roomName, - preset: "private_chat", + preset: Preset.PrivateChat, power_level_content_override: { ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, users: { diff --git a/src/client.ts b/src/client.ts index 90e57f2b1..95e7fbd7f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7735,7 +7735,7 @@ export class MatrixClient extends EventEmitter { public async unstableCreateFileTree(name: string): Promise { const { room_id: roomId } = await this.createRoom({ name: name, - preset: "private_chat", + preset: Preset.PrivateChat, power_level_content_override: { ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, users: { From d7b23a863434aac593625b70b5071a0926ba791c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 14 Jun 2021 13:37:19 -0600 Subject: [PATCH 038/124] liiiinttteeerrrr --- spec/unit/matrix-client.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.js index 02017a4c3..090ffeed1 100644 --- a/spec/unit/matrix-client.spec.js +++ b/spec/unit/matrix-client.spec.js @@ -12,7 +12,7 @@ import { } from "../../src/@types/event"; import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib"; import { MatrixEvent } from "../../src/models/event"; -import {Preset} from "../../src/@types/partials"; +import { Preset } from "../../src/@types/partials"; jest.useFakeTimers(); From ec2a4d473e247c3b19e7d2e03a5d2cb113771774 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 14 Jun 2021 21:28:33 +0100 Subject: [PATCH 039/124] Iterate algorithm, base it on new js-sdk string lib --- src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index e50c512b0..50c5b9f39 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -505,7 +505,7 @@ export function baseToString(n: bigint, alphabet = DEFAULT_ALPHABET): string { const len = BigInt(alphabet.length); if (n <= len) { - return alphabet[Number(n) - 1]; + return alphabet[Number(n) - 1] ?? ""; } let d = n / len; From 045f31a0dcf13013be253bfbe23592e569f68173 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:01:22 +0100 Subject: [PATCH 040/124] Convert some stores to typescript --- src/store/index.ts | 226 ++++++++++++++++++++++++++++ src/store/{memory.js => memory.ts} | 229 ++++++++++++++--------------- src/store/{stub.js => stub.ts} | 156 ++++++++++---------- 3 files changed, 413 insertions(+), 198 deletions(-) create mode 100644 src/store/index.ts rename src/store/{memory.js => memory.ts} (71%) rename src/store/{stub.js => stub.ts} (64%) diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 000000000..3d570e64f --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,226 @@ +/* +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. +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 { EventType } from "../@types/event"; +import { Group } from "../models/group"; +import { Room } from "../models/room"; +import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { Filter } from "../filter"; +import { RoomSummary } from "../models/room-summary"; + +/** + * Construct a stub store. This does no-ops on most store methods. + * @constructor + */ +export interface IStore { + /** @return {Promise} whether or not the database was newly created in this session. */ + isNewlyCreated(): Promise; + + /** + * Get the sync token. + * @return {string} + */ + getSyncToken(): string | null; + + /** + * Set the sync token. + * @param {string} token + */ + setSyncToken(token: string); + + /** + * No-op. + * @param {Group} group + */ + storeGroup(group: Group); + + /** + * No-op. + * @param {string} groupId + * @return {null} + */ + getGroup(groupId: string): Group | null; + + /** + * No-op. + * @return {Array} An empty array. + */ + getGroups(): Group[]; + + /** + * No-op. + * @param {Room} room + */ + storeRoom(room: Room); + + /** + * No-op. + * @param {string} roomId + * @return {null} + */ + getRoom(roomId: string): Room | null; + + /** + * No-op. + * @return {Array} An empty array. + */ + getRooms(): Room[]; + + /** + * Permanently delete a room. + * @param {string} roomId + */ + removeRoom(roomId: string); + + /** + * No-op. + * @return {Array} An empty array. + */ + getRoomSummaries(): RoomSummary[]; + + /** + * No-op. + * @param {User} user + */ + storeUser(user: User); + + /** + * No-op. + * @param {string} userId + * @return {null} + */ + getUser(userId: string): User | null; + + /** + * No-op. + * @return {User[]} + */ + getUsers(): User[]; + + /** + * No-op. + * @param {Room} room + * @param {integer} limit + * @return {Array} + */ + scrollback(room: Room, limit: number): MatrixEvent[]; + + /** + * Store events for a room. + * @param {Room} room The room to store events for. + * @param {Array} events The events to store. + * @param {string} token The token associated with these events. + * @param {boolean} toStart True if these are paginated results. + */ + storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean); + + /** + * Store a filter. + * @param {Filter} filter + */ + storeFilter(filter: Filter); + + /** + * Retrieve a filter. + * @param {string} userId + * @param {string} filterId + * @return {?Filter} A filter or null. + */ + getFilter(userId: string, filterId: string): Filter | null; + + /** + * Retrieve a filter ID with the given name. + * @param {string} filterName The filter name. + * @return {?string} The filter ID or null. + */ + getFilterIdByName(filterName: string): string | null; + + /** + * Set a filter name to ID mapping. + * @param {string} filterName + * @param {string} filterId + */ + setFilterIdByName(filterName: string, filterId: string); + + /** + * Store user-scoped account data events + * @param {Array} events The events to store. + */ + storeAccountDataEvents(events: MatrixEvent[]); + + /** + * Get account data event by event type + * @param {string} eventType The event type being queried + */ + getAccountData(eventType: EventType | string): MatrixEvent; + + /** + * setSyncData does nothing as there is no backing data store. + * + * @param {Object} syncData The sync data + * @return {Promise} An immediately resolved promise. + */ + setSyncData(syncData: object): Promise; + + /** + * We never want to save because we have nothing to save to. + * + * @return {boolean} If the store wants to save + */ + wantsSave(): boolean; + + /** + * Save does nothing as there is no backing data store. + */ + save(force: boolean): void; + + /** + * Startup does nothing. + * @return {Promise} An immediately resolved promise. + */ + startup(): Promise; + + /** + * @return {Promise} Resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + getSavedSync(): Promise; + + /** + * @return {Promise} If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + getSavedSyncToken(): Promise; + + /** + * Delete all data from this store. Does nothing since this store + * doesn't store anything. + * @return {Promise} An immediately resolved promise. + */ + deleteAllData(): Promise; + + getOutOfBandMembers(roomId: string): Promise; + + setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise; + + clearOutOfBandMembers(): Promise; + + getClientOptions(): Promise; + + storeClientOptions(options: object): Promise; +} diff --git a/src/store/memory.js b/src/store/memory.ts similarity index 71% rename from src/store/memory.js rename to src/store/memory.ts index 809696b25..63f03bc5d 100644 --- a/src/store/memory.js +++ b/src/store/memory.ts @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 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. @@ -22,9 +19,18 @@ limitations under the License. * @module store/memory */ +import { EventType } from "../@types/event"; +import { Group } from "../models/group"; +import { Room } from "../models/room"; import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { RoomState } from "../models/room-state"; +import { RoomMember } from "../models/room-member"; +import { Filter } from "../filter"; +import { IStore } from "./index"; +import { RoomSummary } from "../models/room-summary"; -function isValidFilterId(filterId) { +function isValidFilterId(filterId: string): boolean { const isValidStr = typeof filterId === "string" && !!filterId && filterId !== "undefined" && // exclude these as we've serialized undefined in localStorage before @@ -33,6 +39,10 @@ function isValidFilterId(filterId) { return isValidStr || typeof filterId === "number"; } +interface IOpts { + localStorage?: Storage; +} + /** * Construct a new in-memory data store for the Matrix Client. * @constructor @@ -40,96 +50,84 @@ function isValidFilterId(filterId) { * @param {LocalStorage} opts.localStorage The local storage instance to persist * some forms of data such as tokens. Rooms will NOT be stored. */ -export function MemoryStore(opts) { - opts = opts || {}; - this.rooms = { - // roomId: Room - }; - this.groups = { - // groupId: Group - }; - this.users = { - // userId: User - }; - this.syncToken = null; - this.filters = { - // userId: { - // filterId: Filter - // } - }; - this.accountData = { - // type : content - }; - this.localStorage = opts.localStorage; - this._oobMembers = { - // roomId: [member events] - }; - this._clientOptions = {}; -} +export class MemoryStore implements IStore { + private rooms: Record = {}; // roomId: Room + private groups: Record = {}; // groupId: Group + private users: Record = {}; // userId: User + private syncToken: string = null; + // userId: { + // filterId: Filter + // } + private filters: Record> = {}; + private accountData: Record = {}; // type : content + private readonly localStorage: Storage; + private oobMembers: Record = {}; // roomId: [member events] + private clientOptions = {}; -MemoryStore.prototype = { + constructor(opts: IOpts = {}) { + this.localStorage = opts.localStorage; + } /** * Retrieve the token to stream from. * @return {string} The token or null. */ - getSyncToken: function() { + public getSyncToken(): string | null { return this.syncToken; - }, + } - /** @return {Promise} whether or not the database was newly created in this session. */ - isNewlyCreated: function() { + /** @return {Promise} whether or not the database was newly created in this session. */ + public isNewlyCreated(): Promise { return Promise.resolve(true); - }, + } /** * Set the token to stream from. * @param {string} token The token to stream from. */ - setSyncToken: function(token) { + public setSyncToken(token: string) { this.syncToken = token; - }, + } /** * Store the given room. * @param {Group} group The group to be stored */ - storeGroup: function(group) { + public storeGroup(group: Group) { this.groups[group.groupId] = group; - }, + } /** * Retrieve a group by its group ID. * @param {string} groupId The group ID. * @return {Group} The group or null. */ - getGroup: function(groupId) { + public getGroup(groupId: string): Group | null { return this.groups[groupId] || null; - }, + } /** * Retrieve all known groups. * @return {Group[]} A list of groups, which may be empty. */ - getGroups: function() { + public getGroups(): Group[] { return Object.values(this.groups); - }, + } /** * Store the given room. * @param {Room} room The room to be stored. All properties must be stored. */ - storeRoom: function(room) { + public storeRoom(room: Room) { this.rooms[room.roomId] = room; // add listeners for room member changes so we can keep the room member // map up-to-date. - room.currentState.on("RoomState.members", this._onRoomMember.bind(this)); + room.currentState.on("RoomState.members", this.onRoomMember); // add existing members - const self = this; - room.currentState.getMembers().forEach(function(m) { - self._onRoomMember(null, room.currentState, m); + room.currentState.getMembers().forEach((m) => { + this.onRoomMember(null, room.currentState, m); }); - }, + } /** * Called when a room member in a room being tracked by this store has been @@ -138,7 +136,7 @@ MemoryStore.prototype = { * @param {RoomState} state * @param {RoomMember} member */ - _onRoomMember: function(event, state, member) { + private onRoomMember = (event: MatrixEvent, state: RoomState, member: RoomMember) => { if (member.membership === "invite") { // We do NOT add invited members because people love to typo user IDs // which would then show up in these lists (!) @@ -158,70 +156,70 @@ MemoryStore.prototype = { user.setAvatarUrl(member.events.member.getContent().avatar_url); } this.users[user.userId] = user; - }, + }; /** * Retrieve a room by its' room ID. * @param {string} roomId The room ID. * @return {Room} The room or null. */ - getRoom: function(roomId) { + public getRoom(roomId: string): Room | null { return this.rooms[roomId] || null; - }, + } /** * Retrieve all known rooms. * @return {Room[]} A list of rooms, which may be empty. */ - getRooms: function() { + public getRooms(): Room[] { return Object.values(this.rooms); - }, + } /** * Permanently delete a room. * @param {string} roomId */ - removeRoom: function(roomId) { + public removeRoom(roomId: string): void { if (this.rooms[roomId]) { - this.rooms[roomId].removeListener("RoomState.members", this._onRoomMember); + this.rooms[roomId].removeListener("RoomState.members", this.onRoomMember); } delete this.rooms[roomId]; - }, + } /** * Retrieve a summary of all the rooms. * @return {RoomSummary[]} A summary of each room. */ - getRoomSummaries: function() { + public getRoomSummaries(): RoomSummary[] { return Object.values(this.rooms).map(function(room) { return room.summary; }); - }, + } /** * Store a User. * @param {User} user The user to store. */ - storeUser: function(user) { + public storeUser(user: User): void { this.users[user.userId] = user; - }, + } /** * Retrieve a User by its' user ID. * @param {string} userId The user ID. * @return {User} The user or null. */ - getUser: function(userId) { + public getUser(userId: string): User | null { return this.users[userId] || null; - }, + } /** * Retrieve all known users. * @return {User[]} A list of users, which may be empty. */ - getUsers: function() { + public getUsers(): User[] { return Object.values(this.users); - }, + } /** * Retrieve scrollback for this room. @@ -230,9 +228,9 @@ MemoryStore.prototype = { * @return {Array} An array of objects which will be at most 'limit' * length and at least 0. The objects are the raw event JSON. */ - scrollback: function(room, limit) { + public scrollback(room: Room, limit: number): MatrixEvent[] { return []; - }, + } /** * Store events for a room. The events have already been added to the timeline @@ -241,15 +239,15 @@ MemoryStore.prototype = { * @param {string} token The token associated with these events. * @param {boolean} toStart True if these are paginated results. */ - storeEvents: function(room, events, token, toStart) { + public storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean) { // no-op because they've already been added to the room instance. - }, + } /** * Store a filter. * @param {Filter} filter */ - storeFilter: function(filter) { + public storeFilter(filter: Filter): void { if (!filter) { return; } @@ -257,7 +255,7 @@ MemoryStore.prototype = { this.filters[filter.userId] = {}; } this.filters[filter.userId][filter.filterId] = filter; - }, + } /** * Retrieve a filter. @@ -265,19 +263,19 @@ MemoryStore.prototype = { * @param {string} filterId * @return {?Filter} A filter or null. */ - getFilter: function(userId, filterId) { + public getFilter(userId: string, filterId: string): Filter | null { if (!this.filters[userId] || !this.filters[userId][filterId]) { return null; } return this.filters[userId][filterId]; - }, + } /** * Retrieve a filter ID with the given name. * @param {string} filterName The filter name. * @return {?string} The filter ID or null. */ - getFilterIdByName: function(filterName) { + public getFilterIdByName(filterName: string): string | null { if (!this.localStorage) { return null; } @@ -294,14 +292,14 @@ MemoryStore.prototype = { } } catch (e) {} return null; - }, + } /** * Set a filter name to ID mapping. * @param {string} filterName * @param {string} filterId */ - setFilterIdByName: function(filterName, filterId) { + public setFilterIdByName(filterName: string, filterId: string) { if (!this.localStorage) { return; } @@ -313,7 +311,7 @@ MemoryStore.prototype = { this.localStorage.removeItem(key); } } catch (e) {} - }, + } /** * Store user-scoped account data events. @@ -321,21 +319,20 @@ MemoryStore.prototype = { * events with the same type will replace each other. * @param {Array} events The events to store. */ - storeAccountDataEvents: function(events) { - const self = this; - events.forEach(function(event) { - self.accountData[event.getType()] = event; + public storeAccountDataEvents(events: MatrixEvent[]): void { + events.forEach((event) => { + this.accountData[event.getType()] = event; }); - }, + } /** * Get account data event by event type * @param {string} eventType The event type being queried * @return {?MatrixEvent} the user account_data event of given type, if any */ - getAccountData: function(eventType) { + public getAccountData(eventType: EventType | string): MatrixEvent | null { return this.accountData[eventType]; - }, + } /** * setSyncData does nothing as there is no backing data store. @@ -343,56 +340,56 @@ MemoryStore.prototype = { * @param {Object} syncData The sync data * @return {Promise} An immediately resolved promise. */ - setSyncData: function(syncData) { + public setSyncData(syncData: object): Promise { return Promise.resolve(); - }, + } /** * We never want to save becase we have nothing to save to. * * @return {boolean} If the store wants to save */ - wantsSave: function() { + public wantsSave(): boolean { return false; - }, + } /** * Save does nothing as there is no backing data store. * @param {bool} force True to force a save (but the memory * store still can't save anything) */ - save: function(force) {}, + public save(force: boolean): void {} /** * Startup does nothing as this store doesn't require starting up. * @return {Promise} An immediately resolved promise. */ - startup: function() { + public startup(): Promise { return Promise.resolve(); - }, + } /** * @return {Promise} Resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ - getSavedSync: function() { + public getSavedSync(): Promise { return Promise.resolve(null); - }, + } /** * @return {Promise} If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ - getSavedSyncToken: function() { + public getSavedSyncToken(): Promise { return Promise.resolve(null); - }, + } /** * Delete all data from this store. * @return {Promise} An immediately resolved promise. */ - deleteAllData: function() { + public deleteAllData(): Promise { this.rooms = { // roomId: Room }; @@ -409,7 +406,7 @@ MemoryStore.prototype = { // type : content }; return Promise.resolve(); - }, + } /** * Returns the out-of-band membership events for this room that @@ -418,9 +415,9 @@ MemoryStore.prototype = { * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members * @returns {null} in case the members for this room haven't been stored yet */ - getOutOfBandMembers: function(roomId) { - return Promise.resolve(this._oobMembers[roomId] || null); - }, + public getOutOfBandMembers(roomId: string): Promise { + return Promise.resolve(this.oobMembers[roomId] || null); + } /** * Stores the out-of-band membership events for this room. Note that @@ -430,22 +427,22 @@ MemoryStore.prototype = { * @param {event[]} membershipEvents the membership events to store * @returns {Promise} when all members have been stored */ - setOutOfBandMembers: function(roomId, membershipEvents) { - this._oobMembers[roomId] = membershipEvents; + public setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise { + this.oobMembers[roomId] = membershipEvents; return Promise.resolve(); - }, + } - clearOutOfBandMembers: function() { - this._oobMembers = {}; + public clearOutOfBandMembers(): Promise { + this.oobMembers = {}; return Promise.resolve(); - }, + } - getClientOptions: function() { - return Promise.resolve(this._clientOptions); - }, + public getClientOptions(): Promise { + return Promise.resolve(this.clientOptions); + } - storeClientOptions: function(options) { - this._clientOptions = Object.assign({}, options); + public storeClientOptions(options: object): Promise { + this.clientOptions = Object.assign({}, options); return Promise.resolve(); - }, -}; + } +} diff --git a/src/store/stub.js b/src/store/stub.ts similarity index 64% rename from src/store/stub.js rename to src/store/stub.ts index f94e97393..6270741a8 100644 --- a/src/store/stub.js +++ b/src/store/stub.ts @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 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. @@ -22,124 +19,127 @@ limitations under the License. * @module store/stub */ +import { EventType } from "../@types/event"; +import { Group } from "../models/group"; +import { Room } from "../models/room"; +import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { Filter } from "../filter"; +import { IStore } from "./index"; +import { RoomSummary } from "../models/room-summary"; + /** * Construct a stub store. This does no-ops on most store methods. * @constructor */ -export function StubStore() { - this.fromToken = null; -} - -StubStore.prototype = { +export class StubStore implements IStore { + private fromToken: string = null; /** @return {Promise} whether or not the database was newly created in this session. */ - isNewlyCreated: function() { + public isNewlyCreated(): Promise { return Promise.resolve(true); - }, + } /** * Get the sync token. * @return {string} */ - getSyncToken: function() { + public getSyncToken(): string | null { return this.fromToken; - }, + } /** * Set the sync token. * @param {string} token */ - setSyncToken: function(token) { + public setSyncToken(token: string) { this.fromToken = token; - }, + } /** * No-op. * @param {Group} group */ - storeGroup: function(group) { - }, + public storeGroup(group: Group) {} /** * No-op. * @param {string} groupId * @return {null} */ - getGroup: function(groupId) { + public getGroup(groupId: string): Group | null { return null; - }, + } /** * No-op. * @return {Array} An empty array. */ - getGroups: function() { + public getGroups(): Group[] { return []; - }, + } /** * No-op. * @param {Room} room */ - storeRoom: function(room) { - }, + public storeRoom(room: Room) {} /** * No-op. * @param {string} roomId * @return {null} */ - getRoom: function(roomId) { + public getRoom(roomId: string): Room | null { return null; - }, + } /** * No-op. * @return {Array} An empty array. */ - getRooms: function() { + public getRooms(): Room[] { return []; - }, + } /** * Permanently delete a room. * @param {string} roomId */ - removeRoom: function(roomId) { + public removeRoom(roomId: string) { return; - }, + } /** * No-op. * @return {Array} An empty array. */ - getRoomSummaries: function() { + public getRoomSummaries(): RoomSummary[] { return []; - }, + } /** * No-op. * @param {User} user */ - storeUser: function(user) { - }, + public storeUser(user: User) {} /** * No-op. * @param {string} userId * @return {null} */ - getUser: function(userId) { + public getUser(userId: string): User | null { return null; - }, + } /** * No-op. * @return {User[]} */ - getUsers: function() { + public getUsers(): User[] { return []; - }, + } /** * No-op. @@ -147,9 +147,9 @@ StubStore.prototype = { * @param {integer} limit * @return {Array} */ - scrollback: function(room, limit) { + public scrollback(room: Room, limit: number): MatrixEvent[] { return []; - }, + } /** * Store events for a room. @@ -158,15 +158,13 @@ StubStore.prototype = { * @param {string} token The token associated with these events. * @param {boolean} toStart True if these are paginated results. */ - storeEvents: function(room, events, token, toStart) { - }, + public storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean) {} /** * Store a filter. * @param {Filter} filter */ - storeFilter: function(filter) { - }, + public storeFilter(filter: Filter) {} /** * Retrieve a filter. @@ -174,43 +172,37 @@ StubStore.prototype = { * @param {string} filterId * @return {?Filter} A filter or null. */ - getFilter: function(userId, filterId) { + public getFilter(userId: string, filterId: string): Filter | null { return null; - }, + } /** * Retrieve a filter ID with the given name. * @param {string} filterName The filter name. * @return {?string} The filter ID or null. */ - getFilterIdByName: function(filterName) { + public getFilterIdByName(filterName: string): Filter | null { return null; - }, + } /** * Set a filter name to ID mapping. * @param {string} filterName * @param {string} filterId */ - setFilterIdByName: function(filterName, filterId) { - - }, + public setFilterIdByName(filterName: string, filterId: string) {} /** * Store user-scoped account data events * @param {Array} events The events to store. */ - storeAccountDataEvents: function(events) { - - }, + public storeAccountDataEvents(events: MatrixEvent[]) {} /** * Get account data event by event type * @param {string} eventType The event type being queried */ - getAccountData: function(eventType) { - - }, + public getAccountData(eventType: EventType | string): MatrixEvent {} /** * setSyncData does nothing as there is no backing data store. @@ -218,75 +210,75 @@ StubStore.prototype = { * @param {Object} syncData The sync data * @return {Promise} An immediately resolved promise. */ - setSyncData: function(syncData) { + public setSyncData(syncData: object): Promise { return Promise.resolve(); - }, + } /** - * We never want to save becase we have nothing to save to. + * We never want to save because we have nothing to save to. * * @return {boolean} If the store wants to save */ - wantsSave: function() { + public wantsSave(): boolean { return false; - }, + } /** * Save does nothing as there is no backing data store. */ - save: function() {}, + public save() {} /** * Startup does nothing. * @return {Promise} An immediately resolved promise. */ - startup: function() { + public startup(): Promise { return Promise.resolve(); - }, + } /** * @return {Promise} Resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ - getSavedSync: function() { + public getSavedSync(): Promise { return Promise.resolve(null); - }, + } /** * @return {Promise} If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ - getSavedSyncToken: function() { + public getSavedSyncToken(): Promise { return Promise.resolve(null); - }, + } /** * Delete all data from this store. Does nothing since this store * doesn't store anything. * @return {Promise} An immediately resolved promise. */ - deleteAllData: function() { + public deleteAllData(): Promise { return Promise.resolve(); - }, + } - getOutOfBandMembers: function() { + public getOutOfBandMembers(): Promise { return Promise.resolve(null); - }, + } - setOutOfBandMembers: function() { + public setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise { return Promise.resolve(); - }, + } - clearOutOfBandMembers: function() { + public clearOutOfBandMembers(): Promise { return Promise.resolve(); - }, + } - getClientOptions: function() { - return Promise.resolve(); - }, + public getClientOptions(): Promise { + return Promise.resolve({}); + } - storeClientOptions: function() { + public storeClientOptions(options: object): Promise { return Promise.resolve(); - }, -}; + } +} From 265802acb1ce1922590ca068270252c1d61f16b2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:01:38 +0100 Subject: [PATCH 041/124] Convert some models to typescript --- src/@types/event.ts | 5 + src/models/{relations.js => relations.ts} | 173 +++++++++--------- .../{room-summary.js => room-summary.ts} | 16 +- 3 files changed, 103 insertions(+), 91 deletions(-) rename src/models/{relations.js => relations.ts} (66%) rename src/models/{room-summary.js => room-summary.ts} (82%) diff --git a/src/@types/event.ts b/src/@types/event.ts index 6fd3c0338..f7b22992e 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -87,6 +87,11 @@ export enum EventType { Dummy = "m.dummy", } +export enum RelationType { + Annotation = "m.annotation", + Replace = "m.replace", +} + export enum MsgType { Text = "m.text", Emote = "m.emote", diff --git a/src/models/relations.js b/src/models/relations.ts similarity index 66% rename from src/models/relations.js rename to src/models/relations.ts index 50b4ffd65..99485562f 100644 --- a/src/models/relations.js +++ b/src/models/relations.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +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. @@ -14,9 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from 'events'; -import { EventStatus } from '../models/event'; -import { logger } from '../logger'; +import {EventEmitter} from 'events'; + +import {EventStatus, MatrixEvent} from './event'; +import {Room} from './room'; +import {logger} from '../logger'; +import {RelationType} from "../@types/event"; /** * A container for relation events that supports easy access to common ways of @@ -27,8 +30,16 @@ import { logger } from '../logger'; * EventTimelineSet#getRelationsForEvent. */ export class Relations extends EventEmitter { + private relationEventIds = new Set(); + private relations = new Set(); + private annotationsByKey: Record> = {}; + private annotationsBySender: Record> = {}; + private sortedAnnotationsByKey: [string, MatrixEvent][] = []; + private targetEvent: MatrixEvent = null; + private creationEmitted = false; + /** - * @param {String} relationType + * @param {RelationType} relationType * The type of relation involved, such as "m.annotation", "m.reference", * "m.replace", etc. * @param {String} eventType @@ -37,18 +48,12 @@ export class Relations extends EventEmitter { * Room for this container. May be null for non-room cases, such as the * notification timeline. */ - constructor(relationType, eventType, room) { + constructor( + public readonly relationType: RelationType, + public readonly eventType: string, + private readonly room: Room, + ) { super(); - this.relationType = relationType; - this.eventType = eventType; - this._relationEventIds = new Set(); - this._relations = new Set(); - this._annotationsByKey = {}; - this._annotationsBySender = {}; - this._sortedAnnotationsByKey = []; - this._targetEvent = null; - this._room = room; - this._creationEmitted = false; } /** @@ -57,8 +62,8 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} event * The new relation event to be added. */ - async addEvent(event) { - if (this._relationEventIds.has(event.getId())) { + public async addEvent(event: MatrixEvent) { + if (this.relationEventIds.has(event.getId())) { return; } @@ -79,24 +84,24 @@ export class Relations extends EventEmitter { // If the event is in the process of being sent, listen for cancellation // so we can remove the event from the collection. if (event.isSending()) { - event.on("Event.status", this._onEventStatus); + event.on("Event.status", this.onEventStatus); } - this._relations.add(event); - this._relationEventIds.add(event.getId()); + this.relations.add(event); + this.relationEventIds.add(event.getId()); - if (this.relationType === "m.annotation") { - this._addAnnotationToAggregation(event); - } else if (this.relationType === "m.replace" && this._targetEvent) { + if (this.relationType === RelationType.Annotation) { + this.addAnnotationToAggregation(event); + } else if (this.relationType === RelationType.Replace && this.targetEvent) { const lastReplacement = await this.getLastReplacement(); - this._targetEvent.makeReplaced(lastReplacement); + this.targetEvent.makeReplaced(lastReplacement); } - event.on("Event.beforeRedaction", this._onBeforeRedaction); + event.on("Event.beforeRedaction", this.onBeforeRedaction); this.emit("Relations.add", event); - this._maybeEmitCreated(); + this.maybeEmitCreated(); } /** @@ -105,8 +110,8 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} event * The relation event to remove. */ - async _removeEvent(event) { - if (!this._relations.has(event)) { + private async removeEvent(event: MatrixEvent) { + if (!this.relations.has(event)) { return; } @@ -124,13 +129,13 @@ export class Relations extends EventEmitter { return; } - this._relations.delete(event); + this.relations.delete(event); - if (this.relationType === "m.annotation") { - this._removeAnnotationFromAggregation(event); - } else if (this.relationType === "m.replace" && this._targetEvent) { + if (this.relationType === RelationType.Annotation) { + this.removeAnnotationFromAggregation(event); + } else if (this.relationType === RelationType.Replace && this.targetEvent) { const lastReplacement = await this.getLastReplacement(); - this._targetEvent.makeReplaced(lastReplacement); + this.targetEvent.makeReplaced(lastReplacement); } this.emit("Relations.remove", event); @@ -142,18 +147,18 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} event The event whose status has changed * @param {EventStatus} status The new status */ - _onEventStatus = (event, status) => { + private onEventStatus = (event: MatrixEvent, status: EventStatus) => { if (!event.isSending()) { // Sending is done, so we don't need to listen anymore - event.removeListener("Event.status", this._onEventStatus); + event.removeListener("Event.status", this.onEventStatus); return; } if (status !== EventStatus.CANCELLED) { return; } // Event was cancelled, remove from the collection - event.removeListener("Event.status", this._onEventStatus); - this._removeEvent(event); + event.removeListener("Event.status", this.onEventStatus); + this.removeEvent(event); } /** @@ -166,51 +171,51 @@ export class Relations extends EventEmitter { * @return {Array} * Relation events in insertion order. */ - getRelations() { - return [...this._relations]; + public getRelations() { + return [...this.relations]; } - _addAnnotationToAggregation(event) { + private addAnnotationToAggregation(event: MatrixEvent) { const { key } = event.getRelation(); if (!key) { return; } - let eventsForKey = this._annotationsByKey[key]; + let eventsForKey = this.annotationsByKey[key]; if (!eventsForKey) { - eventsForKey = this._annotationsByKey[key] = new Set(); - this._sortedAnnotationsByKey.push([key, eventsForKey]); + eventsForKey = this.annotationsByKey[key] = new Set(); + this.sortedAnnotationsByKey.push([key, eventsForKey]); } // Add the new event to the set for this key eventsForKey.add(event); // Re-sort the [key, events] pairs in descending order of event count - this._sortedAnnotationsByKey.sort((a, b) => { + this.sortedAnnotationsByKey.sort((a, b) => { const aEvents = a[1]; const bEvents = b[1]; return bEvents.size - aEvents.size; }); const sender = event.getSender(); - let eventsFromSender = this._annotationsBySender[sender]; + let eventsFromSender = this.annotationsBySender[sender]; if (!eventsFromSender) { - eventsFromSender = this._annotationsBySender[sender] = new Set(); + eventsFromSender = this.annotationsBySender[sender] = new Set(); } // Add the new event to the set for this sender eventsFromSender.add(event); } - _removeAnnotationFromAggregation(event) { + private removeAnnotationFromAggregation(event: MatrixEvent) { const { key } = event.getRelation(); if (!key) { return; } - const eventsForKey = this._annotationsByKey[key]; + const eventsForKey = this.annotationsByKey[key]; if (eventsForKey) { eventsForKey.delete(event); // Re-sort the [key, events] pairs in descending order of event count - this._sortedAnnotationsByKey.sort((a, b) => { + this.sortedAnnotationsByKey.sort((a, b) => { const aEvents = a[1]; const bEvents = b[1]; return bEvents.size - aEvents.size; @@ -218,7 +223,7 @@ export class Relations extends EventEmitter { } const sender = event.getSender(); - const eventsFromSender = this._annotationsBySender[sender]; + const eventsFromSender = this.annotationsBySender[sender]; if (eventsFromSender) { eventsFromSender.delete(event); } @@ -235,22 +240,22 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} redactedEvent * The original relation event that is about to be redacted. */ - _onBeforeRedaction = async (redactedEvent) => { - if (!this._relations.has(redactedEvent)) { + private onBeforeRedaction = async (redactedEvent: MatrixEvent) => { + if (!this.relations.has(redactedEvent)) { return; } - this._relations.delete(redactedEvent); + this.relations.delete(redactedEvent); - if (this.relationType === "m.annotation") { + if (this.relationType === RelationType.Annotation) { // Remove the redacted annotation from aggregation by key - this._removeAnnotationFromAggregation(redactedEvent); - } else if (this.relationType === "m.replace" && this._targetEvent) { + this.removeAnnotationFromAggregation(redactedEvent); + } else if (this.relationType === RelationType.Replace && this.targetEvent) { const lastReplacement = await this.getLastReplacement(); - this._targetEvent.makeReplaced(lastReplacement); + this.targetEvent.makeReplaced(lastReplacement); } - redactedEvent.removeListener("Event.beforeRedaction", this._onBeforeRedaction); + redactedEvent.removeListener("Event.beforeRedaction", this.onBeforeRedaction); this.emit("Relations.redaction", redactedEvent); } @@ -265,13 +270,13 @@ export class Relations extends EventEmitter { * An array of [key, events] pairs sorted by descending event count. * The events are stored in a Set (which preserves insertion order). */ - getSortedAnnotationsByKey() { - if (this.relationType !== "m.annotation") { + public getSortedAnnotationsByKey() { + if (this.relationType !== RelationType.Annotation) { // Other relation types are not grouped currently. return null; } - return this._sortedAnnotationsByKey; + return this.sortedAnnotationsByKey; } /** @@ -283,13 +288,13 @@ export class Relations extends EventEmitter { * An object with each relation sender as a key and the matching Set of * events for that sender as a value. */ - getAnnotationsBySender() { - if (this.relationType !== "m.annotation") { + public getAnnotationsBySender() { + if (this.relationType !== RelationType.Annotation) { // Other relation types are not grouped currently. return null; } - return this._annotationsBySender; + return this.annotationsBySender; } /** @@ -300,12 +305,12 @@ export class Relations extends EventEmitter { * * @return {MatrixEvent?} */ - async getLastReplacement() { - if (this.relationType !== "m.replace") { + public async getLastReplacement(): Promise { + if (this.relationType !== RelationType.Replace) { // Aggregating on last only makes sense for this relation type return null; } - if (!this._targetEvent) { + if (!this.targetEvent) { // Don't know which replacements to accept yet. // This method shouldn't be called before the original // event is known anyway. @@ -315,11 +320,11 @@ export class Relations extends EventEmitter { // the all-knowning server tells us that the event at some point had // this timestamp for its replacement, so any following replacement should definitely not be less const replaceRelation = - this._targetEvent.getServerAggregatedRelation("m.replace"); + this.targetEvent.getServerAggregatedRelation(RelationType.Replace); const minTs = replaceRelation && replaceRelation.origin_server_ts; const lastReplacement = this.getRelations().reduce((last, event) => { - if (event.getSender() !== this._targetEvent.getSender()) { + if (event.getSender() !== this.targetEvent.getSender()) { return last; } if (minTs && minTs > event.getTs()) { @@ -332,7 +337,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; } @@ -343,38 +348,34 @@ export class Relations extends EventEmitter { /* * @param {MatrixEvent} targetEvent the event the relations are related to. */ - async setTargetEvent(event) { - if (this._targetEvent) { + public async setTargetEvent(event: MatrixEvent) { + if (this.targetEvent) { return; } - this._targetEvent = event; + this.targetEvent = event; - if (this.relationType === "m.replace") { + if (this.relationType === RelationType.Replace) { const replacement = await this.getLastReplacement(); // this is the initial update, so only call it if we already have something // to not emit Event.replaced needlessly if (replacement) { - this._targetEvent.makeReplaced(replacement); + this.targetEvent.makeReplaced(replacement); } } - this._maybeEmitCreated(); + this.maybeEmitCreated(); } - _maybeEmitCreated() { - if (this._creationEmitted) { + private maybeEmitCreated() { + if (this.creationEmitted) { return; } // Only emit we're "created" once we have a target event instance _and_ // at least one related event. - if (!this._targetEvent || !this._relations.size) { + if (!this.targetEvent || !this.relations.size) { return; } - this._creationEmitted = true; - this._targetEvent.emit( - "Event.relationsCreated", - this.relationType, - this.eventType, - ); + this.creationEmitted = true; + this.targetEvent.emit("Event.relationsCreated", this.relationType, this.eventType); } } diff --git a/src/models/room-summary.js b/src/models/room-summary.ts similarity index 82% rename from src/models/room-summary.js rename to src/models/room-summary.ts index 037fe2bd6..f8327f798 100644 --- a/src/models/room-summary.js +++ b/src/models/room-summary.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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. @@ -19,6 +18,14 @@ limitations under the License. * @module models/room-summary */ +interface IInfo { + title: string; + desc: string; + numMembers: number; + aliases: string[]; + timestamp: number; +} + /** * Construct a new Room Summary. A summary can be used for display on a recent * list, without having to load the entire room list into memory. @@ -32,8 +39,7 @@ limitations under the License. * @param {string[]} info.aliases The list of aliases for this room. * @param {Number} info.timestamp The timestamp for this room. */ -export function RoomSummary(roomId, info) { - this.roomId = roomId; - this.info = info; +export class RoomSummary { + constructor(public readonly roomId: string, info?: IInfo) {} } From 2f0d96d030cda33add18eef31515b12f347da397 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:01:51 +0100 Subject: [PATCH 042/124] Convert some utils to typescript --- ...{content-helpers.js => content-helpers.ts} | 14 +++++------ src/{content-repo.js => content-repo.ts} | 25 +++++++++++-------- 2 files changed, 22 insertions(+), 17 deletions(-) rename src/{content-helpers.js => content-helpers.ts} (86%) rename src/{content-repo.js => content-repo.ts} (79%) diff --git a/src/content-helpers.js b/src/content-helpers.ts similarity index 86% rename from src/content-helpers.js rename to src/content-helpers.ts index c82f808c5..061073c5e 100644 --- a/src/content-helpers.js +++ b/src/content-helpers.ts @@ -1,6 +1,6 @@ /* Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2018 - 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. @@ -23,7 +23,7 @@ limitations under the License. * @param {string} htmlBody the HTML representation of the message * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} */ -export function makeHtmlMessage(body, htmlBody) { +export function makeHtmlMessage(body: string, htmlBody: string) { return { msgtype: "m.text", format: "org.matrix.custom.html", @@ -38,7 +38,7 @@ export function makeHtmlMessage(body, htmlBody) { * @param {string} htmlBody the HTML representation of the notice * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} */ -export function makeHtmlNotice(body, htmlBody) { +export function makeHtmlNotice(body: string, htmlBody: string) { return { msgtype: "m.notice", format: "org.matrix.custom.html", @@ -53,7 +53,7 @@ export function makeHtmlNotice(body, htmlBody) { * @param {string} htmlBody the HTML representation of the emote * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} */ -export function makeHtmlEmote(body, htmlBody) { +export function makeHtmlEmote(body: string, htmlBody: string) { return { msgtype: "m.emote", format: "org.matrix.custom.html", @@ -67,7 +67,7 @@ export function makeHtmlEmote(body, htmlBody) { * @param {string} body the plaintext body of the emote * @returns {{msgtype: string, body: string}} */ -export function makeTextMessage(body) { +export function makeTextMessage(body: string) { return { msgtype: "m.text", body: body, @@ -79,7 +79,7 @@ export function makeTextMessage(body) { * @param {string} body the plaintext body of the notice * @returns {{msgtype: string, body: string}} */ -export function makeNotice(body) { +export function makeNotice(body: string) { return { msgtype: "m.notice", body: body, @@ -91,7 +91,7 @@ export function makeNotice(body) { * @param {string} body the plaintext body of the emote * @returns {{msgtype: string, body: string}} */ -export function makeEmoteMessage(body) { +export function makeEmoteMessage(body: string) { return { msgtype: "m.emote", body: body, diff --git a/src/content-repo.js b/src/content-repo.ts similarity index 79% rename from src/content-repo.js rename to src/content-repo.ts index 1b92d59ae..baa91879b 100644 --- a/src/content-repo.js +++ b/src/content-repo.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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. @@ -34,8 +33,14 @@ import * as utils from "./utils"; * for such URLs. * @return {string} The complete URL to the content. */ -export function getHttpUriForMxc(baseUrl, mxc, width, height, - resizeMethod, allowDirectLinks) { +export function getHttpUriForMxc( + baseUrl: string, + mxc: string, + width: number, + height: number, + resizeMethod: string, + allowDirectLinks: boolean, +): string { if (typeof mxc !== "string" || !mxc) { return ''; } @@ -51,13 +56,13 @@ export function getHttpUriForMxc(baseUrl, mxc, width, height, const params = {}; if (width) { - params.width = Math.round(width); + params["width"] = Math.round(width); } if (height) { - params.height = Math.round(height); + params["height"] = Math.round(height); } if (resizeMethod) { - params.method = resizeMethod; + params["method"] = resizeMethod; } if (Object.keys(params).length > 0) { // these are thumbnailing params so they probably want the @@ -71,7 +76,7 @@ export function getHttpUriForMxc(baseUrl, mxc, width, height, fragment = serverAndMediaId.substr(fragmentOffset); serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset); } - return baseUrl + prefix + serverAndMediaId + - (Object.keys(params).length === 0 ? "" : - ("?" + utils.encodeParams(params))) + fragment; + + const urlParams = (Object.keys(params).length === 0 ? "" : ("?" + utils.encodeParams(params))); + return baseUrl + prefix + serverAndMediaId + urlParams + fragment; } From 913710dd99e8017ac635beeec2e5180598504487 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:02:05 +0100 Subject: [PATCH 043/124] Convert filter classes to typescript --- src/filter-component.js | 145 -------------------------- src/filter-component.ts | 146 ++++++++++++++++++++++++++ src/filter.js | 199 ----------------------------------- src/filter.ts | 226 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 372 insertions(+), 344 deletions(-) delete mode 100644 src/filter-component.js create mode 100644 src/filter-component.ts delete mode 100644 src/filter.js create mode 100644 src/filter.ts diff --git a/src/filter-component.js b/src/filter-component.js deleted file mode 100644 index 8ff760673..000000000 --- a/src/filter-component.js +++ /dev/null @@ -1,145 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 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 filter-component - */ - -/** - * Checks if a value matches a given field value, which may be a * terminated - * wildcard pattern. - * @param {String} actual_value The value to be compared - * @param {String} filter_value The filter pattern to be compared - * @return {bool} true if the actual_value matches the filter_value - */ -function _matches_wildcard(actual_value, filter_value) { - if (filter_value.endsWith("*")) { - const type_prefix = filter_value.slice(0, -1); - return actual_value.substr(0, type_prefix.length) === type_prefix; - } else { - return actual_value === filter_value; - } -} - -/** - * FilterComponent is a section of a Filter definition which defines the - * types, rooms, senders filters etc to be applied to a particular type of resource. - * This is all ported over from synapse's Filter object. - * - * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as - * 'Filters' are referred to as 'FilterCollections'. - * - * @constructor - * @param {Object} filter_json the definition of this filter JSON, e.g. { 'contains_url': true } - */ -export function FilterComponent(filter_json) { - this.filter_json = filter_json; - - this.types = filter_json.types || null; - this.not_types = filter_json.not_types || []; - - this.rooms = filter_json.rooms || null; - this.not_rooms = filter_json.not_rooms || []; - - this.senders = filter_json.senders || null; - this.not_senders = filter_json.not_senders || []; - - this.contains_url = filter_json.contains_url || null; -} - -/** - * Checks with the filter component matches the given event - * @param {MatrixEvent} event event to be checked against the filter - * @return {bool} true if the event matches the filter - */ -FilterComponent.prototype.check = function(event) { - return this._checkFields( - event.getRoomId(), - event.getSender(), - event.getType(), - event.getContent() ? event.getContent().url !== undefined : false, - ); -}; - -/** - * Checks whether the filter component matches the given event fields. - * @param {String} room_id the room_id for the event being checked - * @param {String} sender the sender of the event being checked - * @param {String} event_type the type of the event being checked - * @param {String} contains_url whether the event contains a content.url field - * @return {bool} true if the event fields match the filter - */ -FilterComponent.prototype._checkFields = - function(room_id, sender, event_type, contains_url) { - const literal_keys = { - "rooms": function(v) { - return room_id === v; - }, - "senders": function(v) { - return sender === v; - }, - "types": function(v) { - return _matches_wildcard(event_type, v); - }, - }; - - const self = this; - for (let n=0; n < Object.keys(literal_keys).length; n++) { - const name = Object.keys(literal_keys)[n]; - const match_func = literal_keys[name]; - const not_name = "not_" + name; - const disallowed_values = self[not_name]; - if (disallowed_values.filter(match_func).length > 0) { - return false; - } - - const allowed_values = self[name]; - if (allowed_values && allowed_values.length > 0) { - const anyMatch = allowed_values.some(match_func); - if (!anyMatch) { - return false; - } - } - } - - const contains_url_filter = this.filter_json.contains_url; - if (contains_url_filter !== undefined) { - if (contains_url_filter !== contains_url) { - return false; - } - } - - return true; -}; - -/** - * Filters a list of events down to those which match this filter component - * @param {MatrixEvent[]} events Events to be checked againt the filter component - * @return {MatrixEvent[]} events which matched the filter component - */ -FilterComponent.prototype.filter = function(events) { - return events.filter(this.check, this); -}; - -/** - * Returns the limit field for a given filter component, providing a default of - * 10 if none is otherwise specified. Cargo-culted from Synapse. - * @return {Number} the limit for this filter component. - */ -FilterComponent.prototype.limit = function() { - return this.filter_json.limit !== undefined ? this.filter_json.limit : 10; -}; diff --git a/src/filter-component.ts b/src/filter-component.ts new file mode 100644 index 000000000..70c9e2f29 --- /dev/null +++ b/src/filter-component.ts @@ -0,0 +1,146 @@ +/* +Copyright 2016 - 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 { MatrixEvent } from "./models/event"; + +/** + * @module filter-component + */ + +/** + * Checks if a value matches a given field value, which may be a * terminated + * wildcard pattern. + * @param {String} actualValue The value to be compared + * @param {String} filterValue The filter pattern to be compared + * @return {bool} true if the actualValue matches the filterValue + */ +function matchesWildcard(actualValue: string, filterValue: string): boolean { + if (filterValue.endsWith("*")) { + const typePrefix = filterValue.slice(0, -1); + return actualValue.substr(0, typePrefix.length) === typePrefix; + } else { + return actualValue === filterValue; + } +} + +/* eslint-disable camelcase */ +export interface IFilterComponent { + types?: string[]; + not_types?: string[]; + rooms?: string[]; + not_rooms?: string[]; + senders?: string[]; + not_senders?: string[]; + contains_url?: boolean; + limit?: number; +} +/* eslint-enable camelcase */ + +/** + * FilterComponent is a section of a Filter definition which defines the + * types, rooms, senders filters etc to be applied to a particular type of resource. + * This is all ported over from synapse's Filter object. + * + * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as + * 'Filters' are referred to as 'FilterCollections'. + * + * @constructor + * @param {Object} filterJson the definition of this filter JSON, e.g. { 'contains_url': true } + */ +export class FilterComponent { + constructor(private filterJson: IFilterComponent) {} + + /** + * Checks with the filter component matches the given event + * @param {MatrixEvent} event event to be checked against the filter + * @return {bool} true if the event matches the filter + */ + check(event: MatrixEvent): boolean { + return this.checkFields( + event.getRoomId(), + event.getSender(), + event.getType(), + event.getContent() ? event.getContent().url !== undefined : false, + ); + } + + /** + * Checks whether the filter component matches the given event fields. + * @param {String} roomId the roomId for the event being checked + * @param {String} sender the sender of the event being checked + * @param {String} eventType the type of the event being checked + * @param {boolean} containsUrl whether the event contains a content.url field + * @return {boolean} true if the event fields match the filter + */ + private checkFields(roomId: string, sender: string, eventType: string, containsUrl: boolean): boolean { + const literalKeys = { + "rooms": function(v: string): boolean { + return roomId === v; + }, + "senders": function(v: string): boolean { + return sender === v; + }, + "types": function(v: string): boolean { + return matchesWildcard(eventType, v); + }, + }; + + for (let n = 0; n < Object.keys(literalKeys).length; n++) { + const name = Object.keys(literalKeys)[n]; + const matchFunc = literalKeys[name]; + const notName = "not_" + name; + const disallowedValues = this[notName]; + if (disallowedValues.filter(matchFunc).length > 0) { + return false; + } + + const allowedValues = this[name]; + if (allowedValues && allowedValues.length > 0) { + const anyMatch = allowedValues.some(matchFunc); + if (!anyMatch) { + return false; + } + } + } + + const containsUrlFilter = this.filterJson.contains_url; + if (containsUrlFilter !== undefined) { + if (containsUrlFilter !== containsUrl) { + return false; + } + } + + return true; + } + + /** + * Filters a list of events down to those which match this filter component + * @param {MatrixEvent[]} events Events to be checked againt the filter component + * @return {MatrixEvent[]} events which matched the filter component + */ + filter(events: MatrixEvent[]): MatrixEvent[] { + return events.filter(this.check, this); + } + + /** + * Returns the limit field for a given filter component, providing a default of + * 10 if none is otherwise specified. Cargo-culted from Synapse. + * @return {Number} the limit for this filter component. + */ + limit(): number { + return this.filterJson.limit !== undefined ? this.filterJson.limit : 10; + } +} diff --git a/src/filter.js b/src/filter.js deleted file mode 100644 index 08e747092..000000000 --- a/src/filter.js +++ /dev/null @@ -1,199 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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 filter - */ - -import { FilterComponent } from "./filter-component"; - -/** - * @param {Object} obj - * @param {string} keyNesting - * @param {*} val - */ -function setProp(obj, keyNesting, val) { - const nestedKeys = keyNesting.split("."); - let currentObj = obj; - for (let i = 0; i < (nestedKeys.length - 1); i++) { - if (!currentObj[nestedKeys[i]]) { - currentObj[nestedKeys[i]] = {}; - } - currentObj = currentObj[nestedKeys[i]]; - } - currentObj[nestedKeys[nestedKeys.length - 1]] = val; -} - -/** - * Construct a new Filter. - * @constructor - * @param {string} userId The user ID for this filter. - * @param {string=} filterId The filter ID if known. - * @prop {string} userId The user ID of the filter - * @prop {?string} filterId The filter ID - */ -export function Filter(userId, filterId) { - this.userId = userId; - this.filterId = filterId; - this.definition = {}; -} - -Filter.LAZY_LOADING_MESSAGES_FILTER = { - lazy_load_members: true, -}; - -/** - * Get the ID of this filter on your homeserver (if known) - * @return {?Number} The filter ID - */ -Filter.prototype.getFilterId = function() { - return this.filterId; -}; - -/** - * Get the JSON body of the filter. - * @return {Object} The filter definition - */ -Filter.prototype.getDefinition = function() { - return this.definition; -}; - -/** - * Set the JSON body of the filter - * @param {Object} definition The filter definition - */ -Filter.prototype.setDefinition = function(definition) { - this.definition = definition; - - // This is all ported from synapse's FilterCollection() - - // definitions look something like: - // { - // "room": { - // "rooms": ["!abcde:example.com"], - // "not_rooms": ["!123456:example.com"], - // "state": { - // "types": ["m.room.*"], - // "not_rooms": ["!726s6s6q:example.com"], - // "lazy_load_members": true, - // }, - // "timeline": { - // "limit": 10, - // "types": ["m.room.message"], - // "not_rooms": ["!726s6s6q:example.com"], - // "not_senders": ["@spam:example.com"] - // "contains_url": true - // }, - // "ephemeral": { - // "types": ["m.receipt", "m.typing"], - // "not_rooms": ["!726s6s6q:example.com"], - // "not_senders": ["@spam:example.com"] - // } - // }, - // "presence": { - // "types": ["m.presence"], - // "not_senders": ["@alice:example.com"] - // }, - // "event_format": "client", - // "event_fields": ["type", "content", "sender"] - // } - - const room_filter_json = definition.room; - - // consider the top level rooms/not_rooms filter - const room_filter_fields = {}; - if (room_filter_json) { - if (room_filter_json.rooms) { - room_filter_fields.rooms = room_filter_json.rooms; - } - if (room_filter_json.rooms) { - room_filter_fields.not_rooms = room_filter_json.not_rooms; - } - - this._include_leave = room_filter_json.include_leave || false; - } - - this._room_filter = new FilterComponent(room_filter_fields); - this._room_timeline_filter = new FilterComponent( - room_filter_json ? (room_filter_json.timeline || {}) : {}, - ); - - // don't bother porting this from synapse yet: - // this._room_state_filter = - // new FilterComponent(room_filter_json.state || {}); - // this._room_ephemeral_filter = - // new FilterComponent(room_filter_json.ephemeral || {}); - // this._room_account_data_filter = - // new FilterComponent(room_filter_json.account_data || {}); - // this._presence_filter = - // new FilterComponent(definition.presence || {}); - // this._account_data_filter = - // new FilterComponent(definition.account_data || {}); -}; - -/** - * Get the room.timeline filter component of the filter - * @return {FilterComponent} room timeline filter component - */ -Filter.prototype.getRoomTimelineFilterComponent = function() { - return this._room_timeline_filter; -}; - -/** - * Filter the list of events based on whether they are allowed in a timeline - * based on this filter - * @param {MatrixEvent[]} events the list of events being filtered - * @return {MatrixEvent[]} the list of events which match the filter - */ -Filter.prototype.filterRoomTimeline = function(events) { - return this._room_timeline_filter.filter(this._room_filter.filter(events)); -}; - -/** - * Set the max number of events to return for each room's timeline. - * @param {Number} limit The max number of events to return for each room. - */ -Filter.prototype.setTimelineLimit = function(limit) { - setProp(this.definition, "room.timeline.limit", limit); -}; - -Filter.prototype.setLazyLoadMembers = function(enabled) { - setProp(this.definition, "room.state.lazy_load_members", !!enabled); -}; - -/** - * Control whether left rooms should be included in responses. - * @param {boolean} includeLeave True to make rooms the user has left appear - * in responses. - */ -Filter.prototype.setIncludeLeaveRooms = function(includeLeave) { - setProp(this.definition, "room.include_leave", includeLeave); -}; - -/** - * Create a filter from existing data. - * @static - * @param {string} userId - * @param {string} filterId - * @param {Object} jsonObj - * @return {Filter} - */ -Filter.fromJson = function(userId, filterId, jsonObj) { - const filter = new Filter(userId, filterId); - filter.setDefinition(jsonObj); - return filter; -}; diff --git a/src/filter.ts b/src/filter.ts new file mode 100644 index 000000000..a73d91178 --- /dev/null +++ b/src/filter.ts @@ -0,0 +1,226 @@ +/* +Copyright 2015 - 2021 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 filter + */ + +import { FilterComponent, IFilterComponent } from "./filter-component"; +import { MatrixEvent } from "./models/event"; + +/** + * @param {Object} obj + * @param {string} keyNesting + * @param {*} val + */ +function setProp(obj: object, keyNesting: string, val: any) { + const nestedKeys = keyNesting.split("."); + let currentObj = obj; + for (let i = 0; i < (nestedKeys.length - 1); i++) { + if (!currentObj[nestedKeys[i]]) { + currentObj[nestedKeys[i]] = {}; + } + currentObj = currentObj[nestedKeys[i]]; + } + currentObj[nestedKeys[nestedKeys.length - 1]] = val; +} + +/* eslint-disable camelcase */ +interface IFilterDefinition { + event_fields?: string[]; + event_format?: "client" | "federation"; + presence?: IFilterComponent; + account_data?: IFilterComponent; + room?: IRoomFilter; +} + +interface IRoomEventFilter extends IFilterComponent { + lazy_load_members?: boolean; + include_redundant_members?: boolean; +} + +interface IStateFilter extends IRoomEventFilter {} + +interface IRoomFilter { + not_rooms?: string[]; + rooms?: string[]; + ephemeral?: IRoomEventFilter; + include_leave?: boolean; + state?: IStateFilter; + timeline?: IRoomEventFilter; + account_data?: IRoomEventFilter; +} +/* eslint-enable camelcase */ + +/** + * Construct a new Filter. + * @constructor + * @param {string} userId The user ID for this filter. + * @param {string=} filterId The filter ID if known. + * @prop {string} userId The user ID of the filter + * @prop {?string} filterId The filter ID + */ +export class Filter { + static LAZY_LOADING_MESSAGES_FILTER = { + lazy_load_members: true, + }; + + /** + * Create a filter from existing data. + * @static + * @param {string} userId + * @param {string} filterId + * @param {Object} jsonObj + * @return {Filter} + */ + static fromJson(userId: string, filterId: string, jsonObj: IFilterDefinition): Filter { + const filter = new Filter(userId, filterId); + filter.setDefinition(jsonObj); + return filter; + } + + private definition: IFilterDefinition = {}; + private roomFilter: FilterComponent; + private roomTimelineFilter: FilterComponent; + + constructor(public readonly userId: string, public readonly filterId: string) {} + + /** + * Get the ID of this filter on your homeserver (if known) + * @return {?string} The filter ID + */ + getFilterId(): string | null { + return this.filterId; + } + + /** + * Get the JSON body of the filter. + * @return {Object} The filter definition + */ + getDefinition(): IFilterDefinition { + return this.definition; + } + + /** + * Set the JSON body of the filter + * @param {Object} definition The filter definition + */ + setDefinition(definition: IFilterDefinition) { + this.definition = definition; + + // This is all ported from synapse's FilterCollection() + + // definitions look something like: + // { + // "room": { + // "rooms": ["!abcde:example.com"], + // "not_rooms": ["!123456:example.com"], + // "state": { + // "types": ["m.room.*"], + // "not_rooms": ["!726s6s6q:example.com"], + // "lazy_load_members": true, + // }, + // "timeline": { + // "limit": 10, + // "types": ["m.room.message"], + // "not_rooms": ["!726s6s6q:example.com"], + // "not_senders": ["@spam:example.com"] + // "contains_url": true + // }, + // "ephemeral": { + // "types": ["m.receipt", "m.typing"], + // "not_rooms": ["!726s6s6q:example.com"], + // "not_senders": ["@spam:example.com"] + // } + // }, + // "presence": { + // "types": ["m.presence"], + // "not_senders": ["@alice:example.com"] + // }, + // "event_format": "client", + // "event_fields": ["type", "content", "sender"] + // } + + const roomFilterJson = definition.room; + + // consider the top level rooms/not_rooms filter + const roomFilterFields: IRoomFilter = {}; + if (roomFilterJson) { + if (roomFilterJson.rooms) { + roomFilterFields.rooms = roomFilterJson.rooms; + } + if (roomFilterJson.rooms) { + roomFilterFields.not_rooms = roomFilterJson.not_rooms; + } + } + + this.roomFilter = new FilterComponent(roomFilterFields); + this.roomTimelineFilter = new FilterComponent( + roomFilterJson ? (roomFilterJson.timeline || {}) : {}, + ); + + // don't bother porting this from synapse yet: + // this._room_state_filter = + // new FilterComponent(roomFilterJson.state || {}); + // this._room_ephemeral_filter = + // new FilterComponent(roomFilterJson.ephemeral || {}); + // this._room_account_data_filter = + // new FilterComponent(roomFilterJson.account_data || {}); + // this._presence_filter = + // new FilterComponent(definition.presence || {}); + // this._account_data_filter = + // new FilterComponent(definition.account_data || {}); + } + + /** + * Get the room.timeline filter component of the filter + * @return {FilterComponent} room timeline filter component + */ + getRoomTimelineFilterComponent(): FilterComponent { + return this.roomTimelineFilter; + } + + /** + * Filter the list of events based on whether they are allowed in a timeline + * based on this filter + * @param {MatrixEvent[]} events the list of events being filtered + * @return {MatrixEvent[]} the list of events which match the filter + */ + filterRoomTimeline(events: MatrixEvent[]): MatrixEvent[] { + return this.roomTimelineFilter.filter(this.roomFilter.filter(events)); + } + + /** + * Set the max number of events to return for each room's timeline. + * @param {Number} limit The max number of events to return for each room. + */ + setTimelineLimit(limit: number) { + setProp(this.definition, "room.timeline.limit", limit); + } + + setLazyLoadMembers(enabled: boolean) { + setProp(this.definition, "room.state.lazy_load_members", !!enabled); + } + + /** + * Control whether left rooms should be included in responses. + * @param {boolean} includeLeave True to make rooms the user has left appear + * in responses. + */ + setIncludeLeaveRooms(includeLeave: boolean) { + setProp(this.definition, "room.include_leave", includeLeave); + } +} From e0c36498e6f7feb2c48fb63a9c8d941063b0adab Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:06:30 +0100 Subject: [PATCH 044/124] delint --- src/models/relations.ts | 14 +++++++------- src/store/stub.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/models/relations.ts b/src/models/relations.ts index 99485562f..de82ec6e1 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {EventEmitter} from 'events'; +import { EventEmitter } from 'events'; -import {EventStatus, MatrixEvent} from './event'; -import {Room} from './room'; -import {logger} from '../logger'; -import {RelationType} from "../@types/event"; +import { EventStatus, MatrixEvent } from './event'; +import { Room } from './room'; +import { logger } from '../logger'; +import { RelationType } from "../@types/event"; /** * A container for relation events that supports easy access to common ways of @@ -159,7 +159,7 @@ export class Relations extends EventEmitter { // Event was cancelled, remove from the collection event.removeListener("Event.status", this.onEventStatus); this.removeEvent(event); - } + }; /** * Get all relation events in this collection. @@ -258,7 +258,7 @@ export class Relations extends EventEmitter { redactedEvent.removeListener("Event.beforeRedaction", this.onBeforeRedaction); this.emit("Relations.redaction", redactedEvent); - } + }; /** * Get all events in this collection grouped by key and sorted by descending diff --git a/src/store/stub.ts b/src/store/stub.ts index 6270741a8..a1775b3f9 100644 --- a/src/store/stub.ts +++ b/src/store/stub.ts @@ -181,7 +181,7 @@ export class StubStore implements IStore { * @param {string} filterName The filter name. * @return {?string} The filter ID or null. */ - public getFilterIdByName(filterName: string): Filter | null { + public getFilterIdByName(filterName: string): string | null { return null; } From eb5908d5d231ecd8bb48a5877f1decbbee0067a9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 15 Jun 2021 10:15:43 +0100 Subject: [PATCH 045/124] fix tests --- src/filter-component.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/filter-component.ts b/src/filter-component.ts index 70c9e2f29..2d8f7eb51 100644 --- a/src/filter-component.ts +++ b/src/filter-component.ts @@ -102,25 +102,20 @@ export class FilterComponent { const name = Object.keys(literalKeys)[n]; const matchFunc = literalKeys[name]; const notName = "not_" + name; - const disallowedValues = this[notName]; - if (disallowedValues.filter(matchFunc).length > 0) { + const disallowedValues: string[] = this.filterJson[notName]; + if (disallowedValues?.some(matchFunc)) { return false; } - const allowedValues = this[name]; - if (allowedValues && allowedValues.length > 0) { - const anyMatch = allowedValues.some(matchFunc); - if (!anyMatch) { - return false; - } + const allowedValues: string[] = this.filterJson[name]; + if (allowedValues && !allowedValues.some(matchFunc)) { + return false; } } const containsUrlFilter = this.filterJson.contains_url; - if (containsUrlFilter !== undefined) { - if (containsUrlFilter !== containsUrl) { - return false; - } + if (containsUrlFilter !== undefined && containsUrlFilter !== containsUrl) { + return false; } return true; From f4f01913fe89813ca168ae7a464096a9773053e5 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 15 Jun 2021 16:10:48 +0100 Subject: [PATCH 046/124] Prepare changelog for v12.0.0-rc.1 --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 349967cbd..46a0fb78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +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) From 68c1171294f05cb3e2082fb412f2a01ddfcdae99 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 15 Jun 2021 16:10:48 +0100 Subject: [PATCH 047/124] v12.0.0-rc.1 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index f3c06fb6b..26d7d3f76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "11.2.0", + "version": "12.0.0-rc.1", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", @@ -28,7 +28,7 @@ "keywords": [ "matrix-org" ], - "main": "./src/index.ts", + "main": "./lib/index.js", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -108,5 +108,6 @@ "coverageReporters": [ "text" ] - } + }, + "typings": "./lib/index.d.ts" } From 13c9c4bea57b4c8733ed34d650b05d7da37511c0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 7 Jun 2021 20:08:00 -0600 Subject: [PATCH 048/124] Add functions to assist in immutability of Event objects --- spec/unit/models/event.spec.ts | 60 ++++++++++++++++++++++++++++++++++ spec/unit/utils.spec.ts | 36 +++++++++++++++++++- src/models/event.js | 36 ++++++++++++++++++++ src/utils.ts | 25 ++++++++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 spec/unit/models/event.spec.ts diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts new file mode 100644 index 000000000..cafb318e1 --- /dev/null +++ b/spec/unit/models/event.spec.ts @@ -0,0 +1,60 @@ +/* +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 { MatrixEvent } from "../../../src/models/event"; + +describe('MatrixEvent', () => { + it('should create copies of itself', () => { + const a = new MatrixEvent({ + type: "com.example.test", + content: { + isTest: true, + num: 42, + }, + }); + + const clone = a.getSnapshotCopy(); + expect(clone).toBeDefined(); + expect(clone).not.toBe(a); + expect(clone.event).not.toBe(a.event); + expect(clone.event).toMatchObject(a.event); + + // The other properties we're not super interested in, honestly. + }); + + it('should compare itself to other events using json', () => { + const a = new MatrixEvent({ + type: "com.example.test", + content: { + isTest: true, + num: 42, + }, + }); + const b = new MatrixEvent({ + type: "com.example.test______B", + content: { + isTest: true, + num: 42, + }, + }); + expect(a.isEquivalentTo(b)).toBe(false); + expect(a.isEquivalentTo(a)).toBe(true); + expect(b.isEquivalentTo(a)).toBe(false); + expect(b.isEquivalentTo(b)).toBe(true); + expect(a.getSnapshotCopy().isEquivalentTo(a)).toBe(true); + expect(a.getSnapshotCopy().isEquivalentTo(b)).toBe(false); + }); +}); diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index 76123d1ca..5867a9fde 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -2,7 +2,7 @@ import * as utils from "../../src/utils"; import { alphabetPad, averageBetweenStrings, - baseToString, + baseToString, deepSortedObjectEntries, DEFAULT_ALPHABET, lexicographicCompare, nextString, @@ -429,4 +429,38 @@ describe("utils", function() { expect(lexicographicCompare('a', 'A') > 0).toBe(true); }); }); + + describe('deepSortedObjectEntries', () => { + it('should auto-return non-objects', () => { + expect(deepSortedObjectEntries(42)).toEqual(42); + expect(deepSortedObjectEntries("not object")).toEqual("not object"); + expect(deepSortedObjectEntries(true)).toEqual(true); + expect(deepSortedObjectEntries([42])).toEqual([42]); + expect(deepSortedObjectEntries(null)).toEqual(null); + expect(deepSortedObjectEntries(undefined)).toEqual(undefined); + }); + + it('should sort objects appropriately', () => { + const input = { + a: 42, + b: { + d: {}, + a: "test", + b: "alpha", + }, + [72]: "test", + }; + const output = [ + ["72", "test"], + ["a", 42], + ["b", [ + ["a", "test"], + ["b", "alpha"], + ["d", []], + ]], + ]; + + expect(deepSortedObjectEntries(input)).toMatchObject(output); + }); + }); }); diff --git a/src/models/event.js b/src/models/event.js index 1b3bec755..838cd1a3d 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -24,6 +24,7 @@ limitations under the License. import { EventEmitter } from 'events'; import * as utils from '../utils'; import { logger } from '../logger'; +import { deepSortedObjectEntries } from "../utils"; /** * Enum for event statuses. @@ -1143,6 +1144,41 @@ utils.extend(MatrixEvent.prototype, { getTxnId() { return this._txnId; }, + + /** + * Get a copy/snapshot of this event. The returned copy will be loosely linked + * back to this instance, though will have "frozen" event information. Other + * properties may mutate depending on the state of this instance at the time + * of snapshotting. + * + * This is meant to be used to snapshot the event details themselves, not the + * features (such as sender) surrounding the event. + * @returns {MatrixEvent} A snapshot of this event. + */ + getSnapshotCopy() { + const ev = new MatrixEvent(JSON.parse(JSON.stringify(this.event))); + for (const [p, v] of Object.entries(this)) { + if (p !== "event") { // exclude the thing we just cloned + ev[p] = v; + } + } + return ev; + }, + + /** + * Determines if this event is equivalent to the given event. This only checks + * the event object itself, not the other properties of the event. Intended for + * use with getSnapshotCopy() to identify events changing. + * @param {MatrixEvent} otherEvent The other event to check against. + * @returns {boolean} True if the events are the same, false otherwise. + */ + isEquivalentTo(otherEvent) { + if (!otherEvent) return false; + if (otherEvent === this) return true; + const myProps = deepSortedObjectEntries(this.event); + const theirProps = deepSortedObjectEntries(otherEvent.event); + return JSON.stringify(myProps) === JSON.stringify(theirProps); + }, }); /* _REDACT_KEEP_KEY_MAP gives the keys we keep when an event is redacted diff --git a/src/utils.ts b/src/utils.ts index 50c5b9f39..2097e5bb9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -235,6 +235,31 @@ export function deepCompare(x: any, y: any): boolean { return true; } +/** + * Creates an array of object properties/values (entries) then + * sorts the result by key, recursively. The input object must + * ensure it does not have loops. If the input is not an object + * then it will be returned as-is. + * @param {*} obj The object to get entries of + * @returns {[string, *][]} The entries, sorted by key. + */ +export function deepSortedObjectEntries(obj: any): [string, any][] { + if (typeof(obj) !== "object") return obj; + + // Apparently these are object types... + if (obj === null || obj === undefined || Array.isArray(obj)) return obj; + + const pairs: [string, any][] = []; + for (const [k, v] of Object.entries(obj)) { + pairs.push([k, deepSortedObjectEntries(v)]); + } + + // lexicographicCompare is faster than localeCompare, so let's use that. + pairs.sort((a, b) => lexicographicCompare(a[0], b[0])); + + return pairs; +} + /** * Copy properties from one object to another. * From aac77440db3cd66bb868872c4ba15c0e96e3ecfe Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 15 Jun 2021 14:29:26 -0600 Subject: [PATCH 049/124] work around docgen? --- src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index 2097e5bb9..b52a9d3e8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -241,7 +241,7 @@ export function deepCompare(x: any, y: any): boolean { * ensure it does not have loops. If the input is not an object * then it will be returned as-is. * @param {*} obj The object to get entries of - * @returns {[string, *][]} The entries, sorted by key. + * @returns {Array<[string, *]>} The entries, sorted by key. */ export function deepSortedObjectEntries(obj: any): [string, any][] { if (typeof(obj) !== "object") return obj; From 17402e8475f921ee21921905ec059d332bbb0467 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 15 Jun 2021 14:32:53 -0600 Subject: [PATCH 050/124] Work around docgen --- src/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index b52a9d3e8..9a6f510b5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -235,13 +235,14 @@ export function deepCompare(x: any, y: any): boolean { return true; } +// Dev note: This returns a tuple, but jsdoc doesn't like that. https://github.com/jsdoc/jsdoc/issues/1703 /** * Creates an array of object properties/values (entries) then * sorts the result by key, recursively. The input object must * ensure it does not have loops. If the input is not an object * then it will be returned as-is. * @param {*} obj The object to get entries of - * @returns {Array<[string, *]>} The entries, sorted by key. + * @returns {Array} The entries, sorted by key. */ export function deepSortedObjectEntries(obj: any): [string, any][] { if (typeof(obj) !== "object") return obj; From 7862fd9679a74d6371a52b9c96d21c6786f10941 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 16 Jun 2021 20:24:57 +0100 Subject: [PATCH 051/124] just update jsdoc/ts return types due to contention. --- src/client.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/client.ts b/src/client.ts index 95e7fbd7f..5f5c49ba5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3779,10 +3779,10 @@ export class MatrixClient extends EventEmitter { * @param {string} userId * @param {module:client.callback} callback Optional. * @param {string} reason Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public invite(roomId: string, userId: string, callback?: Callback, reason?: string): Promise { + public invite(roomId: string, userId: string, callback?: Callback, reason?: string): Promise<{}> { return this.membershipChange(roomId, userId, "invite", reason, callback); } @@ -3843,10 +3843,10 @@ export class MatrixClient extends EventEmitter { /** * @param {string} roomId * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public leave(roomId: string, callback?: Callback): Promise { + public leave(roomId: string, callback?: Callback): Promise<{}> { return this.membershipChange(roomId, undefined, "leave", undefined, callback); } @@ -3914,10 +3914,10 @@ export class MatrixClient extends EventEmitter { * @param {boolean} deleteRoom True to delete the room from the store on success. * Default: true. * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public forget(roomId: string, deleteRoom?: boolean, callback?: Callback): Promise { + public forget(roomId: string, deleteRoom?: boolean, callback?: Callback): Promise<{}> { if (deleteRoom === undefined) { deleteRoom = true; } @@ -3962,10 +3962,10 @@ export class MatrixClient extends EventEmitter { * @param {string} userId * @param {string} reason Optional. * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public kick(roomId: string, userId: string, reason?: string, callback?: Callback): Promise { + public kick(roomId: string, userId: string, reason?: string, callback?: Callback): Promise<{}> { return this.setMembershipState(roomId, userId, "leave", reason, callback); } @@ -4009,7 +4009,7 @@ export class MatrixClient extends EventEmitter { membership: string, reason?: string, callback?: Callback, - ): Promise { + ): Promise<{}> { if (utils.isFunction(reason)) { callback = reason as any as Callback; // legacy reason = undefined; From 9eb9f3a1176f47ae7e6ad200f090cc175c5eff7e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 16 Jun 2021 20:26:50 +0100 Subject: [PATCH 052/124] also do same update on the 3pid invite paths --- src/client.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index 5f5c49ba5..8c616525c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3791,10 +3791,10 @@ export class MatrixClient extends EventEmitter { * @param {string} roomId The room to invite the user to. * @param {string} email The email address to invite. * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public inviteByEmail(roomId: string, email: string, callback?: Callback): Promise { + public inviteByEmail(roomId: string, email: string, callback?: Callback): Promise<{}> { return this.inviteByThreePid(roomId, "email", email, callback); } @@ -3804,10 +3804,10 @@ export class MatrixClient extends EventEmitter { * @param {string} medium The medium to invite the user e.g. "email". * @param {string} address The address for the specified medium. * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async inviteByThreePid(roomId: string, medium: string, address: string, callback?: Callback): Promise { + public async inviteByThreePid(roomId: string, medium: string, address: string, callback?: Callback): Promise<{}> { const path = utils.encodeUri( "/rooms/$roomId/invite", { $roomId: roomId }, From c545c7ca70cf0e522057390a9276c5f9b2caa68f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 16 Jun 2021 20:32:46 +0100 Subject: [PATCH 053/124] fix MSC3089TreeSpace incorrect type assumption --- src/models/MSC3089TreeSpace.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index f36642a8f..976be06f7 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -112,10 +112,10 @@ export class MSC3089TreeSpace { * @param {string} userId The user ID to invite. * @returns {Promise} Resolves when complete. */ - public invite(userId: string): Promise { + public async invite(userId: string): Promise { // TODO: [@@TR] Reliable invites // TODO: [@@TR] Share keys - return this.client.invite(this.roomId, userId); + await this.client.invite(this.roomId, userId); } /** From d9246176725dc2164fc201f76575119e4a5d1837 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 16 Jun 2021 20:24:35 -0600 Subject: [PATCH 054/124] Add invite retries to file trees --- package.json | 1 + spec/unit/models/MSC3089TreeSpace.spec.ts | 61 +++++++++++++++++++++-- spec/unit/utils.spec.ts | 29 +++++++++++ src/models/MSC3089TreeSpace.ts | 33 ++++++++++-- src/utils.ts | 23 ++++++++- yarn.lock | 19 +++++++ 6 files changed, 158 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index f3c06fb6b..ed4880c40 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "bs58": "^4.0.1", "content-type": "^1.0.4", "loglevel": "^1.7.1", + "p-retry": "^4.5.0", "qs": "^6.9.6", "request": "^2.88.2", "unhomoglyph": "^1.0.6" diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index a99140036..b79670a77 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -25,6 +25,7 @@ import { } from "../../../src/models/MSC3089TreeSpace"; import { DEFAULT_ALPHABET } from "../../../src/utils"; import { MockBlob } from "../../MockBlob"; +import { MatrixError } from "../../../src/http-api"; describe("MSC3089TreeSpace", () => { let client: MatrixClient; @@ -93,7 +94,7 @@ describe("MSC3089TreeSpace", () => { }); client.sendStateEvent = fn; await tree.setName(newName); - expect(fn.mock.calls.length).toBe(1); + expect(fn).toHaveBeenCalledTimes(1); }); it('should support inviting users to the space', async () => { @@ -104,8 +105,62 @@ describe("MSC3089TreeSpace", () => { return Promise.resolve(); }); client.invite = fn; - await tree.invite(target); - expect(fn.mock.calls.length).toBe(1); + await tree.invite(target, false); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should retry invites to the space', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + if (fn.mock.calls.length === 1) return Promise.reject(new Error("Sample Failure")); + return Promise.resolve(); + }); + client.invite = fn; + await tree.invite(target, false); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should not retry invite permission errors', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + return Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN", error: "Sample Failure" })); + }); + client.invite = fn; + try { + await tree.invite(target, false); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.errcode).toEqual("M_FORBIDDEN"); + } + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should invite to subspaces', 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; + tree.getDirectories = () => [ + // Bare minimum overrides. We proxy to our mock function manually so we can + // count the calls, not to ensure accuracy. The invite function behaving correctly + // is covered by another test. + {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, + {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, + {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, + ]; + + await tree.invite(target, true); + expect(fn).toHaveBeenCalledTimes(4); }); async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) { diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index 76123d1ca..a062f7df4 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -7,6 +7,7 @@ import { lexicographicCompare, nextString, prevString, + simpleRetryOperation, stringToBase, } from "../../src/utils"; import { logger } from "../../src/logger"; @@ -267,6 +268,34 @@ describe("utils", function() { }); }); + describe('simpleRetryOperation', () => { + it('should retry', async () => { + let count = 0; + const val = {}; + const fn = (attempt) => { + count++; + + // If this expectation fails then it can appear as a Jest Timeout due to + // the retry running beyond the test limit. + expect(attempt).toEqual(count); + + if (count > 1) { + return Promise.resolve(val); + } else { + return Promise.reject(new Error("Iterative failure")); + } + }; + + const ret = await simpleRetryOperation(fn); + expect(ret).toBe(val); + expect(count).toEqual(2); + }); + + // We don't test much else of the function because then we're just testing that the + // underlying library behaves, which should be tested on its own. Our API surface is + // all that concerns us. + }); + describe('DEFAULT_ALPHABET', () => { it('should be usefully printable ASCII in order', () => { expect(DEFAULT_ALPHABET).toEqual( diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index f36642a8f..5b4771b27 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -19,8 +19,16 @@ import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_M import { Room } from "./room"; import { logger } from "../logger"; import { MatrixEvent } from "./event"; -import { averageBetweenStrings, DEFAULT_ALPHABET, lexicographicCompare, nextString, prevString } from "../utils"; +import { + averageBetweenStrings, + DEFAULT_ALPHABET, + lexicographicCompare, + nextString, + prevString, + simpleRetryOperation, +} from "../utils"; import { MSC3089Branch } from "./MSC3089Branch"; +import promiseRetry from "p-retry"; /** * The recommended defaults for a tree space's power levels. Note that this @@ -110,12 +118,29 @@ export class MSC3089TreeSpace { * 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. + * @param {boolean} andSubspaces True (default) to invite the user to all + * directories/subspaces too, recursively. * @returns {Promise} Resolves when complete. */ - public invite(userId: string): Promise { - // TODO: [@@TR] Reliable invites + public invite(userId: string, andSubspaces = true): Promise { // TODO: [@@TR] Share keys - return this.client.invite(this.roomId, userId); + const promises: Promise[] = [this.retryInvite(userId)]; + if (andSubspaces) { + promises.push(...this.getDirectories().map(d => d.invite(userId, andSubspaces))); + } + return Promise.all(promises).then(); // .then() to coerce types + } + + private retryInvite(userId: string): Promise { + return simpleRetryOperation(() => { + return this.client.invite(this.roomId, userId).catch(e => { + // We don't want to retry permission errors forever... + if (e?.errcode === "M_FORBIDDEN") { + throw new promiseRetry.AbortError(e); + } + throw e; + }); + }); } /** diff --git a/src/utils.ts b/src/utils.ts index 50c5b9f39..a2504a011 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,7 +20,8 @@ limitations under the License. * @module utils */ -import unhomoglyph from 'unhomoglyph'; +import unhomoglyph from "unhomoglyph"; +import promiseRetry from "p-retry"; /** * Encode a dictionary of query parameters. @@ -443,6 +444,26 @@ export async function chunkPromises(fns: (() => Promise)[], chunkSize: num return results; } +/** + * Retries the function until it succeeds or is interrupted. The given function must return + * a promise which throws/rejects on error, otherwise the retry will assume the request + * succeeded. The promise chain returned will contain the successful promise. The given function + * should always return a new promise. + * @param {Function} promiseFn The function to call to get a fresh promise instance. Takes an + * attempt count as an argument, for logging/debugging purposes. + * @returns {Promise} The promise for the retried operation. + */ +export function simpleRetryOperation(promiseFn: (attempt: number) => Promise): Promise { + return promiseRetry((attempt: number) => { + return promiseFn(attempt); + }, { + forever: true, + factor: 2, + minTimeout: 3000, // ms + maxTimeout: 15000, // ms + }); +} + // We need to be able to access the Node.js crypto library from within the // Matrix SDK without needing to `require("crypto")`, which will fail in // browsers. So `index.ts` will call `setCrypto` to store it, and when we need diff --git a/yarn.lock b/yarn.lock index 04057af8b..b582d75fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1132,6 +1132,7 @@ "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz": version "3.2.3" + uid cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4 resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents": @@ -1305,6 +1306,11 @@ "@types/tough-cookie" "*" form-data "^2.5.0" +"@types/retry@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/stack-utils@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" @@ -5220,6 +5226,14 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" +p-retry@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.5.0.tgz#6685336b3672f9ee8174d3769a660cb5e488521d" + integrity sha512-5Hwh4aVQSu6BEP+w2zKlVXtFAaYQe1qWuVADSgoeVlLjwe/Q/AMSoRR4MDeaAfu8llT+YNbEijWu/YF3m6avkg== + dependencies: + "@types/retry" "^0.12.0" + retry "^0.12.0" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -5943,6 +5957,11 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" From fe5bfbf76f3a7bbe1f300055f529e8aae4af8db5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 16 Jun 2021 20:27:03 -0600 Subject: [PATCH 055/124] The linter needed appeasing --- spec/unit/models/MSC3089TreeSpace.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index b79670a77..a7f42e07b 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -154,9 +154,9 @@ describe("MSC3089TreeSpace", () => { // Bare minimum overrides. We proxy to our mock function manually so we can // count the calls, not to ensure accuracy. The invite function behaving correctly // is covered by another test. - {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, - {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, - {invite: (userId) => fn(tree.roomId, userId)} as MSC3089TreeSpace, + { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, + { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, + { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, ]; await tree.invite(target, true); From b780ee8373d28891eac4b7b55948dccd1776d003 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 10:23:09 +0100 Subject: [PATCH 056/124] Update src/models/relations.ts Co-authored-by: J. Ryan Stinnett --- src/models/relations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/relations.ts b/src/models/relations.ts index de82ec6e1..5d70cffee 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -34,7 +34,7 @@ export class Relations extends EventEmitter { private relations = new Set(); private annotationsByKey: Record> = {}; private annotationsBySender: Record> = {}; - private sortedAnnotationsByKey: [string, MatrixEvent][] = []; + private sortedAnnotationsByKey: [string, Set][] = []; private targetEvent: MatrixEvent = null; private creationEmitted = false; From 4b5653c09befd8b41ae177801dec0e3ba5334e49 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 11:36:24 +0100 Subject: [PATCH 057/124] Convert IndexedDBStore to TS --- src/store/indexeddb.js | 319 ---------------------------------------- src/store/indexeddb.ts | 323 +++++++++++++++++++++++++++++++++++++++++ src/store/memory.ts | 2 +- 3 files changed, 324 insertions(+), 320 deletions(-) delete mode 100644 src/store/indexeddb.js create mode 100644 src/store/indexeddb.ts diff --git a/src/store/indexeddb.js b/src/store/indexeddb.js deleted file mode 100644 index e661c83a8..000000000 --- a/src/store/indexeddb.js +++ /dev/null @@ -1,319 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 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. -*/ - -/* eslint-disable @babel/no-invalid-this */ - -import { MemoryStore } from "./memory"; -import * as utils from "../utils"; -import { EventEmitter } from 'events'; -import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend.js"; -import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend.js"; -import { User } from "../models/user"; -import { MatrixEvent } from "../models/event"; -import { logger } from '../logger'; - -/** - * This is an internal module. See {@link IndexedDBStore} for the public class. - * @module store/indexeddb - */ - -// If this value is too small we'll be writing very often which will cause -// noticable stop-the-world pauses. If this value is too big we'll be writing -// so infrequently that the /sync size gets bigger on reload. Writing more -// often does not affect the length of the pause since the entire /sync -// response is persisted each time. -const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes - -/** - * Construct a new Indexed Database store, which extends MemoryStore. - * - * This store functions like a MemoryStore except it periodically persists - * the contents of the store to an IndexedDB backend. - * - * All data is still kept in-memory but can be loaded from disk by calling - * startup(). This can make startup times quicker as a complete - * sync from the server is not required. This does not reduce memory usage as all - * the data is eagerly fetched when startup() is called. - *
- * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
- * let store = new IndexedDBStore(opts);
- * await store.startup(); // load from indexed db
- * let client = sdk.createClient({
- *     store: store,
- * });
- * client.startClient();
- * client.on("sync", function(state, prevState, data) {
- *     if (state === "PREPARED") {
- *         console.log("Started up, now with go faster stripes!");
- *     }
- * });
- * 
- * - * @constructor - * @extends MemoryStore - * @param {Object} opts Options object. - * @param {Object} opts.indexedDB The Indexed DB interface e.g. - * window.indexedDB - * @param {string=} opts.dbName Optional database name. The same name must be used - * to open the same database. - * @param {string=} opts.workerScript Optional URL to a script to invoke a web - * worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker - * class is provided for this purpose and requires the application to provide a - * trivial wrapper script around it. - * @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker - * object will be used if it exists. - * @prop {IndexedDBStoreBackend} backend The backend instance. Call through to - * this API if you need to perform specific indexeddb actions like deleting the - * database. - */ -export function IndexedDBStore(opts) { - MemoryStore.call(this, opts); - - if (!opts.indexedDB) { - throw new Error('Missing required option: indexedDB'); - } - - if (opts.workerScript) { - // try & find a webworker-compatible API - let workerApi = opts.workerApi; - if (!workerApi) { - // default to the global Worker object (which is where it in a browser) - workerApi = global.Worker; - } - this.backend = new RemoteIndexedDBStoreBackend( - opts.workerScript, opts.dbName, workerApi, - ); - } else { - this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName); - } - - this.startedUp = false; - this._syncTs = 0; - - // Records the last-modified-time of each user at the last point we saved - // the database, such that we can derive the set if users that have been - // modified since we last saved. - this._userModifiedMap = { - // user_id : timestamp - }; -} -utils.inherits(IndexedDBStore, MemoryStore); -utils.extend(IndexedDBStore.prototype, EventEmitter.prototype); - -IndexedDBStore.exists = function(indexedDB, dbName) { - return LocalIndexedDBStoreBackend.exists(indexedDB, dbName); -}; - -/** - * @return {Promise} Resolved when loaded from indexed db. - */ -IndexedDBStore.prototype.startup = function() { - if (this.startedUp) { - logger.log(`IndexedDBStore.startup: already started`); - return Promise.resolve(); - } - - logger.log(`IndexedDBStore.startup: connecting to backend`); - return this.backend.connect().then(() => { - logger.log(`IndexedDBStore.startup: loading presence events`); - return this.backend.getUserPresenceEvents(); - }).then((userPresenceEvents) => { - logger.log(`IndexedDBStore.startup: processing presence events`); - userPresenceEvents.forEach(([userId, rawEvent]) => { - const u = new User(userId); - if (rawEvent) { - u.setPresenceEvent(new MatrixEvent(rawEvent)); - } - this._userModifiedMap[u.userId] = u.getLastModifiedTime(); - this.storeUser(u); - }); - }); -}; - -/** - * @return {Promise} Resolves with a sync response to restore the - * client state to where it was at the last save, or null if there - * is no saved sync data. - */ -IndexedDBStore.prototype.getSavedSync = degradable(function() { - return this.backend.getSavedSync(); -}, "getSavedSync"); - -/** @return {Promise} whether or not the database was newly created in this session. */ -IndexedDBStore.prototype.isNewlyCreated = degradable(function() { - return this.backend.isNewlyCreated(); -}, "isNewlyCreated"); - -/** - * @return {Promise} If there is a saved sync, the nextBatch token - * for this sync, otherwise null. - */ -IndexedDBStore.prototype.getSavedSyncToken = degradable(function() { - return this.backend.getNextBatchToken(); -}, "getSavedSyncToken"), - -/** - * Delete all data from this store. - * @return {Promise} Resolves if the data was deleted from the database. - */ -IndexedDBStore.prototype.deleteAllData = degradable(function() { - MemoryStore.prototype.deleteAllData.call(this); - return this.backend.clearDatabase().then(() => { - logger.log("Deleted indexeddb data."); - }, (err) => { - logger.error(`Failed to delete indexeddb data: ${err}`); - throw err; - }); -}); - -/** - * Whether this store would like to save its data - * Note that obviously whether the store wants to save or - * not could change between calling this function and calling - * save(). - * - * @return {boolean} True if calling save() will actually save - * (at the time this function is called). - */ -IndexedDBStore.prototype.wantsSave = function() { - const now = Date.now(); - return now - this._syncTs > WRITE_DELAY_MS; -}; - -/** - * Possibly write data to the database. - * - * @param {bool} force True to force a save to happen - * @return {Promise} Promise resolves after the write completes - * (or immediately if no write is performed) - */ -IndexedDBStore.prototype.save = function(force) { - if (force || this.wantsSave()) { - return this._reallySave(); - } - return Promise.resolve(); -}; - -IndexedDBStore.prototype._reallySave = degradable(function() { - this._syncTs = Date.now(); // set now to guard against multi-writes - - // work out changed users (this doesn't handle deletions but you - // can't 'delete' users as they are just presence events). - const userTuples = []; - for (const u of this.getUsers()) { - if (this._userModifiedMap[u.userId] === u.getLastModifiedTime()) continue; - if (!u.events.presence) continue; - - userTuples.push([u.userId, u.events.presence.event]); - - // note that we've saved this version of the user - this._userModifiedMap[u.userId] = u.getLastModifiedTime(); - } - - return this.backend.syncToDatabase(userTuples); -}); - -IndexedDBStore.prototype.setSyncData = degradable(function(syncData) { - return this.backend.setSyncData(syncData); -}, "setSyncData"); - -/** - * Returns the out-of-band membership events for this room that - * were previously loaded. - * @param {string} roomId - * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members - * @returns {null} in case the members for this room haven't been stored yet - */ -IndexedDBStore.prototype.getOutOfBandMembers = degradable(function(roomId) { - return this.backend.getOutOfBandMembers(roomId); -}, "getOutOfBandMembers"); - -/** - * Stores the out-of-band membership events for this room. Note that - * it still makes sense to store an empty array as the OOB status for the room is - * marked as fetched, and getOutOfBandMembers will return an empty array instead of null - * @param {string} roomId - * @param {event[]} membershipEvents the membership events to store - * @returns {Promise} when all members have been stored - */ -IndexedDBStore.prototype.setOutOfBandMembers = degradable(function( - roomId, - membershipEvents, -) { - MemoryStore.prototype.setOutOfBandMembers.call(this, roomId, membershipEvents); - return this.backend.setOutOfBandMembers(roomId, membershipEvents); -}, "setOutOfBandMembers"); - -IndexedDBStore.prototype.clearOutOfBandMembers = degradable(function(roomId) { - MemoryStore.prototype.clearOutOfBandMembers.call(this); - return this.backend.clearOutOfBandMembers(roomId); -}, "clearOutOfBandMembers"); - -IndexedDBStore.prototype.getClientOptions = degradable(function() { - return this.backend.getClientOptions(); -}, "getClientOptions"); - -IndexedDBStore.prototype.storeClientOptions = degradable(function(options) { - MemoryStore.prototype.storeClientOptions.call(this, options); - return this.backend.storeClientOptions(options); -}, "storeClientOptions"); - -/** - * All member functions of `IndexedDBStore` that access the backend use this wrapper to - * watch for failures after initial store startup, including `QuotaExceededError` as - * free disk space changes, etc. - * - * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore` - * in place so that the current operation and all future ones are in-memory only. - * - * @param {Function} func The degradable work to do. - * @param {String} fallback The method name for fallback. - * @returns {Function} A wrapped member function. - */ -function degradable(func, fallback) { - return async function(...args) { - try { - return await func.call(this, ...args); - } catch (e) { - logger.error("IndexedDBStore failure, degrading to MemoryStore", e); - this.emit("degraded", e); - try { - // We try to delete IndexedDB after degrading since this store is only a - // cache (the app will still function correctly without the data). - // It's possible that deleting repair IndexedDB for the next app load, - // potenially by making a little more space available. - logger.log("IndexedDBStore trying to delete degraded data"); - await this.backend.clearDatabase(); - logger.log("IndexedDBStore delete after degrading succeeeded"); - } catch (e) { - logger.warn("IndexedDBStore delete after degrading failed", e); - } - // Degrade the store from being an instance of `IndexedDBStore` to instead be - // an instance of `MemoryStore` so that future API calls use the memory path - // directly and skip IndexedDB entirely. This should be safe as - // `IndexedDBStore` already extends from `MemoryStore`, so we are making the - // store become its parent type in a way. The mutator methods of - // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are - // not overridden at all). - Object.setPrototypeOf(this, MemoryStore.prototype); - if (fallback) { - return await MemoryStore.prototype[fallback].call(this, ...args); - } - } - }; -} diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts new file mode 100644 index 000000000..e268e5a11 --- /dev/null +++ b/src/store/indexeddb.ts @@ -0,0 +1,323 @@ +/* +Copyright 2017 - 2021 Vector Creations Ltd + +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. +*/ + +/* eslint-disable @babel/no-invalid-this */ + +import { EventEmitter } from 'events'; + +import { MemoryStore, IOpts as IBaseOpts } from "./memory"; +import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend.js"; +import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend.js"; +import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { logger } from '../logger'; + +/** + * This is an internal module. See {@link IndexedDBStore} for the public class. + * @module store/indexeddb + */ + +// If this value is too small we'll be writing very often which will cause +// noticeable stop-the-world pauses. If this value is too big we'll be writing +// so infrequently that the /sync size gets bigger on reload. Writing more +// often does not affect the length of the pause since the entire /sync +// response is persisted each time. +const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes + +interface IOpts extends IBaseOpts { + indexedDB: IDBFactory; + dbName?: string; + workerScript?: string; + workerApi?: typeof Worker; +} + +export class IndexedDBStore extends MemoryStore { + static exists(indexedDB: IDBFactory, dbName: string): boolean { + return LocalIndexedDBStoreBackend.exists(indexedDB, dbName); + } + + // TODO these should conform to one interface + public readonly backend: LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; + + private startedUp = false; + private syncTs = 0; + // Records the last-modified-time of each user at the last point we saved + // the database, such that we can derive the set if users that have been + // modified since we last saved. + private userModifiedMap: Record = {}; // user_id : timestamp + private emitter = new EventEmitter(); + + /** + * Construct a new Indexed Database store, which extends MemoryStore. + * + * This store functions like a MemoryStore except it periodically persists + * the contents of the store to an IndexedDB backend. + * + * All data is still kept in-memory but can be loaded from disk by calling + * startup(). This can make startup times quicker as a complete + * sync from the server is not required. This does not reduce memory usage as all + * the data is eagerly fetched when startup() is called. + *
+     * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
+     * let store = new IndexedDBStore(opts);
+     * await store.startup(); // load from indexed db
+     * let client = sdk.createClient({
+     *     store: store,
+     * });
+     * client.startClient();
+     * client.on("sync", function(state, prevState, data) {
+     *     if (state === "PREPARED") {
+     *         console.log("Started up, now with go faster stripes!");
+     *     }
+     * });
+     * 
+ * + * @constructor + * @extends MemoryStore + * @param {Object} opts Options object. + * @param {Object} opts.indexedDB The Indexed DB interface e.g. + * window.indexedDB + * @param {string=} opts.dbName Optional database name. The same name must be used + * to open the same database. + * @param {string=} opts.workerScript Optional URL to a script to invoke a web + * worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker + * class is provided for this purpose and requires the application to provide a + * trivial wrapper script around it. + * @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker + * object will be used if it exists. + * @prop {IndexedDBStoreBackend} backend The backend instance. Call through to + * this API if you need to perform specific indexeddb actions like deleting the + * database. + */ + constructor(opts: IOpts) { + super(opts); + + if (!opts.indexedDB) { + throw new Error('Missing required option: indexedDB'); + } + + if (opts.workerScript) { + // try & find a webworker-compatible API + let workerApi = opts.workerApi; + if (!workerApi) { + // default to the global Worker object (which is where it in a browser) + workerApi = global.Worker; + } + this.backend = new RemoteIndexedDBStoreBackend( + opts.workerScript, opts.dbName, workerApi, + ); + } else { + this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName); + } + } + + public on = this.emitter.on.bind(this.emitter); + + /** + * @return {Promise} Resolved when loaded from indexed db. + */ + public startup(): Promise { + if (this.startedUp) { + logger.log(`IndexedDBStore.startup: already started`); + return Promise.resolve(); + } + + logger.log(`IndexedDBStore.startup: connecting to backend`); + return this.backend.connect().then(() => { + logger.log(`IndexedDBStore.startup: loading presence events`); + return this.backend.getUserPresenceEvents(); + }).then((userPresenceEvents) => { + logger.log(`IndexedDBStore.startup: processing presence events`); + userPresenceEvents.forEach(([userId, rawEvent]) => { + const u = new User(userId); + if (rawEvent) { + u.setPresenceEvent(new MatrixEvent(rawEvent)); + } + this.userModifiedMap[u.userId] = u.getLastModifiedTime(); + this.storeUser(u); + }); + }); + } + + /** + * @return {Promise} Resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + public getSavedSync = this.degradable(() => { + return this.backend.getSavedSync(); + }, "getSavedSync"); + + /** @return {Promise} whether or not the database was newly created in this session. */ + public isNewlyCreated = this.degradable(() => { + return this.backend.isNewlyCreated(); + }, "isNewlyCreated"); + + /** + * @return {Promise} If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + public getSavedSyncToken = this.degradable(() => { + return this.backend.getNextBatchToken(); + }, "getSavedSyncToken"); + + /** + * Delete all data from this store. + * @return {Promise} Resolves if the data was deleted from the database. + */ + public deleteAllData = this.degradable(() => { + super.deleteAllData(); + return this.backend.clearDatabase().then(() => { + logger.log("Deleted indexeddb data."); + }, (err) => { + logger.error(`Failed to delete indexeddb data: ${err}`); + throw err; + }); + }); + + /** + * Whether this store would like to save its data + * Note that obviously whether the store wants to save or + * not could change between calling this function and calling + * save(). + * + * @return {boolean} True if calling save() will actually save + * (at the time this function is called). + */ + public wantsSave(): boolean { + const now = Date.now(); + return now - this.syncTs > WRITE_DELAY_MS; + } + + /** + * Possibly write data to the database. + * + * @param {boolean} force True to force a save to happen + * @return {Promise} Promise resolves after the write completes + * (or immediately if no write is performed) + */ + public save(force = false): Promise { + if (force || this.wantsSave()) { + return this.reallySave(); + } + return Promise.resolve(); + } + + private reallySave = this.degradable((): void => { + this.syncTs = Date.now(); // set now to guard against multi-writes + + // work out changed users (this doesn't handle deletions but you + // can't 'delete' users as they are just presence events). + const userTuples = []; + for (const u of this.getUsers()) { + if (this.userModifiedMap[u.userId] === u.getLastModifiedTime()) continue; + if (!u.events.presence) continue; + + userTuples.push([u.userId, u.events.presence.event]); + + // note that we've saved this version of the user + this.userModifiedMap[u.userId] = u.getLastModifiedTime(); + } + + return this.backend.syncToDatabase(userTuples); + }); + + public setSyncData = this.degradable((syncData: object) => { + return this.backend.setSyncData(syncData); + }, "setSyncData"); + + /** + * Returns the out-of-band membership events for this room that + * were previously loaded. + * @param {string} roomId + * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members + * @returns {null} in case the members for this room haven't been stored yet + */ + public getOutOfBandMembers = this.degradable((roomId: string) => { + return this.backend.getOutOfBandMembers(roomId); + }, "getOutOfBandMembers"); + + /** + * Stores the out-of-band membership events for this room. Note that + * it still makes sense to store an empty array as the OOB status for the room is + * marked as fetched, and getOutOfBandMembers will return an empty array instead of null + * @param {string} roomId + * @param {event[]} membershipEvents the membership events to store + * @returns {Promise} when all members have been stored + */ + public setOutOfBandMembers = this.degradable((roomId: string, membershipEvents: MatrixEvent[]) => { + super.setOutOfBandMembers(roomId, membershipEvents); + return this.backend.setOutOfBandMembers(roomId, membershipEvents); + }, "setOutOfBandMembers"); + + public clearOutOfBandMembers = this.degradable((roomId: string) => { + super.clearOutOfBandMembers(); + return this.backend.clearOutOfBandMembers(roomId); + }, "clearOutOfBandMembers"); + + public getClientOptions = this.degradable(() => { + return this.backend.getClientOptions(); + }, "getClientOptions"); + + public storeClientOptions = this.degradable((options) => { + super.storeClientOptions(options); + return this.backend.storeClientOptions(options); + }, "storeClientOptions"); + + /** + * All member functions of `IndexedDBStore` that access the backend use this wrapper to + * watch for failures after initial store startup, including `QuotaExceededError` as + * free disk space changes, etc. + * + * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore` + * in place so that the current operation and all future ones are in-memory only. + * + * @param {Function} func The degradable work to do. + * @param {String} fallback The method name for fallback. + * @returns {Function} A wrapped member function. + */ + private degradable(func, fallback?: string) { + return async (...args) => { + try { + return await func.call(this, ...args); + } catch (e) { + logger.error("IndexedDBStore failure, degrading to MemoryStore", e); + this.emitter.emit("degraded", e); + try { + // We try to delete IndexedDB after degrading since this store is only a + // cache (the app will still function correctly without the data). + // It's possible that deleting repair IndexedDB for the next app load, + // potentially by making a little more space available. + logger.log("IndexedDBStore trying to delete degraded data"); + await this.backend.clearDatabase(); + logger.log("IndexedDBStore delete after degrading succeeded"); + } catch (e) { + logger.warn("IndexedDBStore delete after degrading failed", e); + } + // Degrade the store from being an instance of `IndexedDBStore` to instead be + // an instance of `MemoryStore` so that future API calls use the memory path + // directly and skip IndexedDB entirely. This should be safe as + // `IndexedDBStore` already extends from `MemoryStore`, so we are making the + // store become its parent type in a way. The mutator methods of + // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are + // not overridden at all). + if (fallback) { + return await super[fallback](...args); + } + } + }; + } +} diff --git a/src/store/memory.ts b/src/store/memory.ts index 63f03bc5d..984e2f82b 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -39,7 +39,7 @@ function isValidFilterId(filterId: string): boolean { return isValidStr || typeof filterId === "number"; } -interface IOpts { +export interface IOpts { localStorage?: Storage; } From 64f369b5de76103ea66eabb4c2fadf75a748d28a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 12:10:48 +0100 Subject: [PATCH 058/124] Fix IndexedDBStore ts-ification --- src/store/index.ts | 2 +- src/store/indexeddb.ts | 37 ++++++++++++++++++++++--------------- src/store/memory.ts | 2 +- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index 3d570e64f..a49f0ad23 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -218,7 +218,7 @@ export interface IStore { setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise; - clearOutOfBandMembers(): Promise; + clearOutOfBandMembers(roomId: string): Promise; getClientOptions(): Promise; diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts index e268e5a11..2e83d2ff1 100644 --- a/src/store/indexeddb.ts +++ b/src/store/indexeddb.ts @@ -157,12 +157,12 @@ export class IndexedDBStore extends MemoryStore { * client state to where it was at the last save, or null if there * is no saved sync data. */ - public getSavedSync = this.degradable(() => { + public getSavedSync = this.degradable((): Promise => { return this.backend.getSavedSync(); }, "getSavedSync"); /** @return {Promise} whether or not the database was newly created in this session. */ - public isNewlyCreated = this.degradable(() => { + public isNewlyCreated = this.degradable((): Promise => { return this.backend.isNewlyCreated(); }, "isNewlyCreated"); @@ -170,7 +170,7 @@ export class IndexedDBStore extends MemoryStore { * @return {Promise} If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ - public getSavedSyncToken = this.degradable(() => { + public getSavedSyncToken = this.degradable((): Promise => { return this.backend.getNextBatchToken(); }, "getSavedSyncToken"); @@ -178,7 +178,7 @@ export class IndexedDBStore extends MemoryStore { * Delete all data from this store. * @return {Promise} Resolves if the data was deleted from the database. */ - public deleteAllData = this.degradable(() => { + public deleteAllData = this.degradable((): Promise => { super.deleteAllData(); return this.backend.clearDatabase().then(() => { logger.log("Deleted indexeddb data."); @@ -216,7 +216,7 @@ export class IndexedDBStore extends MemoryStore { return Promise.resolve(); } - private reallySave = this.degradable((): void => { + private reallySave = this.degradable((): Promise => { this.syncTs = Date.now(); // set now to guard against multi-writes // work out changed users (this doesn't handle deletions but you @@ -235,7 +235,7 @@ export class IndexedDBStore extends MemoryStore { return this.backend.syncToDatabase(userTuples); }); - public setSyncData = this.degradable((syncData: object) => { + public setSyncData = this.degradable((syncData: object): Promise => { return this.backend.setSyncData(syncData); }, "setSyncData"); @@ -246,7 +246,7 @@ export class IndexedDBStore extends MemoryStore { * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members * @returns {null} in case the members for this room haven't been stored yet */ - public getOutOfBandMembers = this.degradable((roomId: string) => { + public getOutOfBandMembers = this.degradable((roomId: string): Promise => { return this.backend.getOutOfBandMembers(roomId); }, "getOutOfBandMembers"); @@ -258,21 +258,21 @@ export class IndexedDBStore extends MemoryStore { * @param {event[]} membershipEvents the membership events to store * @returns {Promise} when all members have been stored */ - public setOutOfBandMembers = this.degradable((roomId: string, membershipEvents: MatrixEvent[]) => { + public setOutOfBandMembers = this.degradable((roomId: string, membershipEvents: MatrixEvent[]): Promise => { super.setOutOfBandMembers(roomId, membershipEvents); return this.backend.setOutOfBandMembers(roomId, membershipEvents); }, "setOutOfBandMembers"); public clearOutOfBandMembers = this.degradable((roomId: string) => { - super.clearOutOfBandMembers(); + super.clearOutOfBandMembers(roomId); return this.backend.clearOutOfBandMembers(roomId); }, "clearOutOfBandMembers"); - public getClientOptions = this.degradable(() => { + public getClientOptions = this.degradable((): Promise => { return this.backend.getClientOptions(); }, "getClientOptions"); - public storeClientOptions = this.degradable((options) => { + public storeClientOptions = this.degradable((options: object): Promise => { super.storeClientOptions(options); return this.backend.storeClientOptions(options); }, "storeClientOptions"); @@ -289,10 +289,15 @@ export class IndexedDBStore extends MemoryStore { * @param {String} fallback The method name for fallback. * @returns {Function} A wrapped member function. */ - private degradable(func, fallback?: string) { + private degradable, R = void>( + func: DegradableFn, + fallback?: string, + ): DegradableFn { + const fallbackFn = super[fallback]; + return async (...args) => { try { - return await func.call(this, ...args); + return func.call(this, ...args); } catch (e) { logger.error("IndexedDBStore failure, degrading to MemoryStore", e); this.emitter.emit("degraded", e); @@ -314,10 +319,12 @@ export class IndexedDBStore extends MemoryStore { // store become its parent type in a way. The mutator methods of // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are // not overridden at all). - if (fallback) { - return await super[fallback](...args); + if (fallbackFn) { + return fallbackFn(...args); } } }; } } + +type DegradableFn, T> = (...args: A) => Promise; diff --git a/src/store/memory.ts b/src/store/memory.ts index 984e2f82b..eda2adf3f 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -432,7 +432,7 @@ export class MemoryStore implements IStore { return Promise.resolve(); } - public clearOutOfBandMembers(): Promise { + public clearOutOfBandMembers(roomId: string): Promise { this.oobMembers = {}; return Promise.resolve(); } From c4664a185ff402528d85b52eb128a6effa3d21f2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:03:34 +0100 Subject: [PATCH 059/124] Convert Event Context to TS --- src/models/event-context.js | 115 --------------------------------- src/models/event-context.ts | 123 ++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 115 deletions(-) delete mode 100644 src/models/event-context.js create mode 100644 src/models/event-context.ts diff --git a/src/models/event-context.js b/src/models/event-context.js deleted file mode 100644 index b04018aba..000000000 --- a/src/models/event-context.js +++ /dev/null @@ -1,115 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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 models/event-context - */ - -/** - * Construct a new EventContext - * - * An eventcontext is used for circumstances such as search results, when we - * have a particular event of interest, and a bunch of events before and after - * it. - * - * It also stores pagination tokens for going backwards and forwards in the - * timeline. - * - * @param {MatrixEvent} ourEvent the event at the centre of this context - * - * @constructor - */ -export function EventContext(ourEvent) { - this._timeline = [ourEvent]; - this._ourEventIndex = 0; - this._paginateTokens = { b: null, f: null }; - - // this is used by MatrixClient to keep track of active requests - this._paginateRequests = { b: null, f: null }; -} - -/** - * Get the main event of interest - * - * This is a convenience function for getTimeline()[getOurEventIndex()]. - * - * @return {MatrixEvent} The event at the centre of this context. - */ -EventContext.prototype.getEvent = function() { - return this._timeline[this._ourEventIndex]; -}; - -/** - * Get the list of events in this context - * - * @return {Array} An array of MatrixEvents - */ -EventContext.prototype.getTimeline = function() { - return this._timeline; -}; - -/** - * Get the index in the timeline of our event - * - * @return {Number} - */ -EventContext.prototype.getOurEventIndex = function() { - return this._ourEventIndex; -}; - -/** - * Get a pagination token. - * - * @param {boolean} backwards true to get the pagination token for going - * backwards in time - * @return {string} - */ -EventContext.prototype.getPaginateToken = function(backwards) { - return this._paginateTokens[backwards ? 'b' : 'f']; -}; - -/** - * Set a pagination token. - * - * Generally this will be used only by the matrix js sdk. - * - * @param {string} token pagination token - * @param {boolean} backwards true to set the pagination token for going - * backwards in time - */ -EventContext.prototype.setPaginateToken = function(token, backwards) { - this._paginateTokens[backwards ? 'b' : 'f'] = token; -}; - -/** - * Add more events to the timeline - * - * @param {Array} events new events, in timeline order - * @param {boolean} atStart true to insert new events at the start - */ -EventContext.prototype.addEvents = function(events, atStart) { - // TODO: should we share logic with Room.addEventsToTimeline? - // Should Room even use EventContext? - - if (atStart) { - this._timeline = events.concat(this._timeline); - this._ourEventIndex += events.length; - } else { - this._timeline = this._timeline.concat(events); - } -}; - diff --git a/src/models/event-context.ts b/src/models/event-context.ts new file mode 100644 index 000000000..95bc83e6c --- /dev/null +++ b/src/models/event-context.ts @@ -0,0 +1,123 @@ +/* +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. +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 { MatrixEvent } from "./event"; + +enum Direction { + Backward = "b", + Forward = "f", +} + +/** + * @module models/event-context + */ +export class EventContext { + private timeline: MatrixEvent[]; + private ourEventIndex = 0; + private paginateTokens: Record = { + [Direction.Backward]: null, + [Direction.Forward]: null, + }; + + /** + * Construct a new EventContext + * + * An eventcontext is used for circumstances such as search results, when we + * have a particular event of interest, and a bunch of events before and after + * it. + * + * It also stores pagination tokens for going backwards and forwards in the + * timeline. + * + * @param {MatrixEvent} ourEvent the event at the centre of this context + * + * @constructor + */ + constructor(ourEvent: MatrixEvent) { + this.timeline = [ourEvent]; + } + + /** + * Get the main event of interest + * + * This is a convenience function for getTimeline()[getOurEventIndex()]. + * + * @return {MatrixEvent} The event at the centre of this context. + */ + public getEvent(): MatrixEvent { + return this.timeline[this.ourEventIndex]; + } + + /** + * Get the list of events in this context + * + * @return {Array} An array of MatrixEvents + */ + public getTimeline(): MatrixEvent[] { + return this.timeline; + } + + /** + * Get the index in the timeline of our event + * + * @return {Number} + */ + public getOurEventIndex(): number { + return this.ourEventIndex; + } + + /** + * Get a pagination token. + * + * @param {boolean} backwards true to get the pagination token for going + * backwards in time + * @return {string} + */ + public getPaginateToken(backwards = false): string { + return this.paginateTokens[backwards ? Direction.Backward : Direction.Forward]; + } + + /** + * Set a pagination token. + * + * Generally this will be used only by the matrix js sdk. + * + * @param {string} token pagination token + * @param {boolean} backwards true to set the pagination token for going + * backwards in time + */ + public setPaginateToken(token: string, backwards = false): void { + this.paginateTokens[backwards ? Direction.Backward : Direction.Forward] = token; + } + + /** + * Add more events to the timeline + * + * @param {Array} events new events, in timeline order + * @param {boolean} atStart true to insert new events at the start + */ + public addEvents(events: MatrixEvent[], atStart = false): void { + // TODO: should we share logic with Room.addEventsToTimeline? + // Should Room even use EventContext? + + if (atStart) { + this.timeline = events.concat(this.timeline); + this.ourEventIndex += events.length; + } else { + this.timeline = this.timeline.concat(events); + } + } +} From f5e8fe836ee0393ae9e267f0d4c235e422b744f9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:03:52 +0100 Subject: [PATCH 060/124] Convert Room Member and User to TS --- src/models/room-member.js | 393 ------------------------------------ src/models/room-member.ts | 411 ++++++++++++++++++++++++++++++++++++++ src/models/user.js | 260 ------------------------ src/models/user.ts | 273 +++++++++++++++++++++++++ 4 files changed, 684 insertions(+), 653 deletions(-) delete mode 100644 src/models/room-member.js create mode 100644 src/models/room-member.ts delete mode 100644 src/models/user.js create mode 100644 src/models/user.ts diff --git a/src/models/room-member.js b/src/models/room-member.js deleted file mode 100644 index 63b6906ba..000000000 --- a/src/models/room-member.js +++ /dev/null @@ -1,393 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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 models/room-member - */ - -import { EventEmitter } from "events"; -import { getHttpUriForMxc } from "../content-repo"; -import * as utils from "../utils"; - -/** - * Construct a new room member. - * - * @constructor - * @alias module:models/room-member - * - * @param {string} roomId The room ID of the member. - * @param {string} userId The user ID of the member. - * @prop {string} roomId The room ID for this member. - * @prop {string} userId The user ID of this member. - * @prop {boolean} typing True if the room member is currently typing. - * @prop {string} name The human-readable name for this room member. This will be - * disambiguated with a suffix of " (@user_id:matrix.org)" if another member shares the - * same displayname. - * @prop {string} rawDisplayName The ambiguous displayname of this room member. - * @prop {Number} powerLevel The power level for this room member. - * @prop {Number} powerLevelNorm The normalised power level (0-100) for this - * room member. - * @prop {User} user The User object for this room member, if one exists. - * @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; - this.userId = userId; - this.typing = false; - this.name = userId; - this.rawDisplayName = userId; - this.powerLevel = 0; - this.powerLevelNorm = 0; - this.user = null; - this.membership = null; - this.events = { - member: null, - }; - this._isOutOfBand = false; - this._updateModifiedTime(); - this.disambiguate = false; -} -utils.inherits(RoomMember, EventEmitter); - -/** - * Mark the member as coming from a channel that is not sync - */ -RoomMember.prototype.markOutOfBand = function() { - this._isOutOfBand = true; -}; - -/** - * @return {bool} does the member come from a channel that is not sync? - * This is used to store the member seperately - * from the sync state so it available across browser sessions. - */ -RoomMember.prototype.isOutOfBand = function() { - return this._isOutOfBand; -}; - -/** - * Update this room member's membership event. May fire "RoomMember.name" if - * this event updates this member's name. - * @param {MatrixEvent} event The m.room.member event - * @param {RoomState} roomState Optional. The room state to take into account - * when calculating (e.g. for disambiguating users with the same name). - * @fires module:client~MatrixClient#event:"RoomMember.name" - * @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; - } - - this._isOutOfBand = false; - - this.events.member = event; - - 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, - displayName, - roomState, - this.disambiguate, - ); - - this.rawDisplayName = event.getDirectionalContent().displayname || this.userId; - if (oldMembership !== this.membership) { - this._updateModifiedTime(); - this.emit("RoomMember.membership", event, this, oldMembership); - } - if (oldName !== this.name) { - this._updateModifiedTime(); - this.emit("RoomMember.name", event, this, oldName); - } -}; - -/** - * Update this room member's power level event. May fire - * "RoomMember.powerLevel" if this event updates this member's power levels. - * @param {MatrixEvent} powerLevelEvent The m.room.power_levels - * event - * @fires module:client~MatrixClient#event:"RoomMember.powerLevel" - */ -RoomMember.prototype.setPowerLevelEvent = function(powerLevelEvent) { - if (powerLevelEvent.getType() !== "m.room.power_levels") { - return; - } - - const evContent = powerLevelEvent.getDirectionalContent(); - - let maxLevel = evContent.users_default || 0; - const users = evContent.users || {}; - Object.values(users).forEach(function(lvl) { - maxLevel = Math.max(maxLevel, lvl); - }); - const oldPowerLevel = this.powerLevel; - const oldPowerLevelNorm = this.powerLevelNorm; - - if (users[this.userId] !== undefined) { - this.powerLevel = users[this.userId]; - } else if (evContent.users_default !== undefined) { - this.powerLevel = evContent.users_default; - } else { - this.powerLevel = 0; - } - this.powerLevelNorm = 0; - if (maxLevel > 0) { - this.powerLevelNorm = (this.powerLevel * 100) / maxLevel; - } - - // emit for changes in powerLevelNorm as well (since the app will need to - // redraw everyone's level if the max has changed) - if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) { - this._updateModifiedTime(); - this.emit("RoomMember.powerLevel", powerLevelEvent, this); - } -}; - -/** - * Update this room member's typing event. May fire "RoomMember.typing" if - * this event changes this member's typing state. - * @param {MatrixEvent} event The typing event - * @fires module:client~MatrixClient#event:"RoomMember.typing" - */ -RoomMember.prototype.setTypingEvent = function(event) { - if (event.getType() !== "m.typing") { - return; - } - const oldTyping = this.typing; - this.typing = false; - const typingList = event.getContent().user_ids; - if (!Array.isArray(typingList)) { - // malformed event :/ bail early. TODO: whine? - return; - } - if (typingList.indexOf(this.userId) !== -1) { - this.typing = true; - } - if (oldTyping !== this.typing) { - this._updateModifiedTime(); - this.emit("RoomMember.typing", event, this); - } -}; - -/** - * Update the last modified time to the current time. - */ -RoomMember.prototype._updateModifiedTime = function() { - this._modified = Date.now(); -}; - -/** - * Get the timestamp when this RoomMember was last updated. This timestamp is - * updated when properties on this RoomMember are updated. - * It is updated before firing events. - * @return {number} The timestamp - */ -RoomMember.prototype.getLastModifiedTime = function() { - return this._modified; -}; - -RoomMember.prototype.isKicked = function() { - return this.membership === "leave" && - this.events.member.getSender() !== this.events.member.getStateKey(); -}; - -/** - * If this member was invited with the is_direct flag set, return - * the user that invited this member - * @return {string} user id of the inviter - */ -RoomMember.prototype.getDMInviter = function() { - // when not available because that room state hasn't been loaded in, - // we don't really know, but more likely to not be a direct chat - if (this.events.member) { - // TODO: persist the is_direct flag on the member as more member events - // come in caused by displayName changes. - - // the is_direct flag is set on the invite member event. - // This is copied on the prev_content section of the join member event - // when the invite is accepted. - - const memberEvent = this.events.member; - let memberContent = memberEvent.getContent(); - let inviteSender = memberEvent.getSender(); - - if (memberContent.membership === "join") { - memberContent = memberEvent.getPrevContent(); - inviteSender = memberEvent.getUnsigned().prev_sender; - } - - if (memberContent.membership === "invite" && memberContent.is_direct) { - return inviteSender; - } - } -}; - -/** - * Get the avatar URL for a room member. - * @param {string} baseUrl The base homeserver URL See - * {@link module:client~MatrixClient#getHomeserverUrl}. - * @param {Number} width The desired width of the thumbnail. - * @param {Number} height The desired height of the thumbnail. - * @param {string} resizeMethod The thumbnail resize method to use, either - * "crop" or "scale". - * @param {Boolean} allowDefault (optional) Passing false causes this method to - * return null if the user has no avatar image. Otherwise, a default image URL - * will be returned. Default: true. (Deprecated) - * @param {Boolean} allowDirectLinks (optional) If true, the avatar URL will be - * returned even if it is a direct hyperlink rather than a matrix content URL. - * If false, any non-matrix content URLs will be ignored. Setting this option to - * true will expose URLs that, if fetched, will leak information about the user - * to anyone who they share a room with. - * @return {?string} the avatar URL or null. - */ -RoomMember.prototype.getAvatarUrl = - function(baseUrl, width, height, resizeMethod, allowDefault, allowDirectLinks) { - if (allowDefault === undefined) { - allowDefault = true; - } - - const rawUrl = this.getMxcAvatarUrl(); - - if (!rawUrl && !allowDefault) { - return null; - } - const httpUrl = getHttpUriForMxc( - baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks, - ); - if (httpUrl) { - return httpUrl; - } - return null; -}; -/** - * get the mxc avatar url, either from a state event, or from a lazily loaded member - * @return {string} the mxc avatar url - */ -RoomMember.prototype.getMxcAvatarUrl = function() { - if (this.events.member) { - return this.events.member.getDirectionalContent().avatar_url; - } else if (this.user) { - return this.user.avatarUrl; - } - return null; -}; - -const MXID_PATTERN = /@.+:.+/; -const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; - -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 false; - - 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 - if (MXID_PATTERN.test(displayName)) return true; - - // 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; - - // 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; - - return displayName; -} - -/** - * Fires whenever any room member's name changes. - * @event module:client~MatrixClient#"RoomMember.name" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.name changed. - * @param {string?} oldName The previous name. Null if the member didn't have a - * name previously. - * @example - * matrixClient.on("RoomMember.name", function(event, member){ - * var newName = member.name; - * }); - */ - -/** - * Fires whenever any room member's membership state changes. - * @event module:client~MatrixClient#"RoomMember.membership" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.membership changed. - * @param {string?} oldMembership The previous membership state. Null if it's a - * new member. - * @example - * matrixClient.on("RoomMember.membership", function(event, member, oldMembership){ - * var newState = member.membership; - * }); - */ - -/** - * Fires whenever any room member's typing state changes. - * @event module:client~MatrixClient#"RoomMember.typing" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.typing changed. - * @example - * matrixClient.on("RoomMember.typing", function(event, member){ - * var isTyping = member.typing; - * }); - */ - -/** - * Fires whenever any room member's power level changes. - * @event module:client~MatrixClient#"RoomMember.powerLevel" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.powerLevel changed. - * @example - * matrixClient.on("RoomMember.powerLevel", function(event, member){ - * var newPowerLevel = member.powerLevel; - * var newNormPowerLevel = member.powerLevelNorm; - * }); - */ diff --git a/src/models/room-member.ts b/src/models/room-member.ts new file mode 100644 index 000000000..de9437c07 --- /dev/null +++ b/src/models/room-member.ts @@ -0,0 +1,411 @@ +/* +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. +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 models/room-member + */ + +import { EventEmitter } from "events"; + +import { getHttpUriForMxc } from "../content-repo"; +import * as utils from "../utils"; +import { User } from "./user"; +import { MatrixEvent } from "./event"; +import { RoomState } from "./room-state"; + +export class RoomMember extends EventEmitter { + private _isOutOfBand = false; + private _modified: number; + + // XXX these should be read-only + public typing = false; + public name: string; + public rawDisplayName: string; + public powerLevel = 0; + public powerLevelNorm = 0; + public user?: User = null; + public membership: string = null; + public disambiguate = false; + public events: { + member?: MatrixEvent; + } = { + member: null, + }; + + /** + * Construct a new room member. + * + * @constructor + * @alias module:models/room-member + * + * @param {string} roomId The room ID of the member. + * @param {string} userId The user ID of the member. + * @prop {string} roomId The room ID for this member. + * @prop {string} userId The user ID of this member. + * @prop {boolean} typing True if the room member is currently typing. + * @prop {string} name The human-readable name for this room member. This will be + * disambiguated with a suffix of " (@user_id:matrix.org)" if another member shares the + * same displayname. + * @prop {string} rawDisplayName The ambiguous displayname of this room member. + * @prop {Number} powerLevel The power level for this room member. + * @prop {Number} powerLevelNorm The normalised power level (0-100) for this + * room member. + * @prop {User} user The User object for this room member, if one exists. + * @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. + */ + constructor(public readonly roomId: string, public readonly userId: string) { + super(); + + this.name = userId; + this.rawDisplayName = userId; + this.updateModifiedTime(); + } + + /** + * Mark the member as coming from a channel that is not sync + */ + public markOutOfBand(): void { + this._isOutOfBand = true; + } + + /** + * @return {boolean} does the member come from a channel that is not sync? + * This is used to store the member seperately + * from the sync state so it available across browser sessions. + */ + public isOutOfBand(): boolean { + return this._isOutOfBand; + } + + /** + * Update this room member's membership event. May fire "RoomMember.name" if + * this event updates this member's name. + * @param {MatrixEvent} event The m.room.member event + * @param {RoomState} roomState Optional. The room state to take into account + * when calculating (e.g. for disambiguating users with the same name). + * @fires module:client~MatrixClient#event:"RoomMember.name" + * @fires module:client~MatrixClient#event:"RoomMember.membership" + */ + public setMembershipEvent(event: MatrixEvent, roomState: RoomState): void { + const displayName = event.getDirectionalContent().displayname; + + if (event.getType() !== "m.room.member") { + return; + } + + this._isOutOfBand = false; + + this.events.member = event; + + 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, + displayName, + roomState, + this.disambiguate, + ); + + this.rawDisplayName = event.getDirectionalContent().displayname || this.userId; + if (oldMembership !== this.membership) { + this.updateModifiedTime(); + this.emit("RoomMember.membership", event, this, oldMembership); + } + if (oldName !== this.name) { + this.updateModifiedTime(); + this.emit("RoomMember.name", event, this, oldName); + } + } + + /** + * Update this room member's power level event. May fire + * "RoomMember.powerLevel" if this event updates this member's power levels. + * @param {MatrixEvent} powerLevelEvent The m.room.power_levels + * event + * @fires module:client~MatrixClient#event:"RoomMember.powerLevel" + */ + public setPowerLevelEvent(powerLevelEvent: MatrixEvent): void { + if (powerLevelEvent.getType() !== "m.room.power_levels") { + return; + } + + const evContent = powerLevelEvent.getDirectionalContent(); + + let maxLevel = evContent.users_default || 0; + const users = evContent.users || {}; + Object.values(users).forEach(function(lvl: number) { + maxLevel = Math.max(maxLevel, lvl); + }); + const oldPowerLevel = this.powerLevel; + const oldPowerLevelNorm = this.powerLevelNorm; + + if (users[this.userId] !== undefined) { + this.powerLevel = users[this.userId]; + } else if (evContent.users_default !== undefined) { + this.powerLevel = evContent.users_default; + } else { + this.powerLevel = 0; + } + this.powerLevelNorm = 0; + if (maxLevel > 0) { + this.powerLevelNorm = (this.powerLevel * 100) / maxLevel; + } + + // emit for changes in powerLevelNorm as well (since the app will need to + // redraw everyone's level if the max has changed) + if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) { + this.updateModifiedTime(); + this.emit("RoomMember.powerLevel", powerLevelEvent, this); + } + } + + /** + * Update this room member's typing event. May fire "RoomMember.typing" if + * this event changes this member's typing state. + * @param {MatrixEvent} event The typing event + * @fires module:client~MatrixClient#event:"RoomMember.typing" + */ + public setTypingEvent(event: MatrixEvent): void { + if (event.getType() !== "m.typing") { + return; + } + const oldTyping = this.typing; + this.typing = false; + const typingList = event.getContent().user_ids; + if (!Array.isArray(typingList)) { + // malformed event :/ bail early. TODO: whine? + return; + } + if (typingList.indexOf(this.userId) !== -1) { + this.typing = true; + } + if (oldTyping !== this.typing) { + this.updateModifiedTime(); + this.emit("RoomMember.typing", event, this); + } + } + + /** + * Update the last modified time to the current time. + */ + private updateModifiedTime() { + this._modified = Date.now(); + } + + /** + * Get the timestamp when this RoomMember was last updated. This timestamp is + * updated when properties on this RoomMember are updated. + * It is updated before firing events. + * @return {number} The timestamp + */ + public getLastModifiedTime(): number { + return this._modified; + } + + public isKicked(): boolean { + return this.membership === "leave" && + this.events.member.getSender() !== this.events.member.getStateKey(); + } + + /** + * If this member was invited with the is_direct flag set, return + * the user that invited this member + * @return {string} user id of the inviter + */ + public getDMInviter(): string { + // when not available because that room state hasn't been loaded in, + // we don't really know, but more likely to not be a direct chat + if (this.events.member) { + // TODO: persist the is_direct flag on the member as more member events + // come in caused by displayName changes. + + // the is_direct flag is set on the invite member event. + // This is copied on the prev_content section of the join member event + // when the invite is accepted. + + const memberEvent = this.events.member; + let memberContent = memberEvent.getContent(); + let inviteSender = memberEvent.getSender(); + + if (memberContent.membership === "join") { + memberContent = memberEvent.getPrevContent(); + inviteSender = memberEvent.getUnsigned().prev_sender; + } + + if (memberContent.membership === "invite" && memberContent.is_direct) { + return inviteSender; + } + } + } + + /** + * Get the avatar URL for a room member. + * @param {string} baseUrl The base homeserver URL See + * {@link module:client~MatrixClient#getHomeserverUrl}. + * @param {Number} width The desired width of the thumbnail. + * @param {Number} height The desired height of the thumbnail. + * @param {string} resizeMethod The thumbnail resize method to use, either + * "crop" or "scale". + * @param {Boolean} allowDefault (optional) Passing false causes this method to + * return null if the user has no avatar image. Otherwise, a default image URL + * will be returned. Default: true. (Deprecated) + * @param {Boolean} allowDirectLinks (optional) If true, the avatar URL will be + * returned even if it is a direct hyperlink rather than a matrix content URL. + * If false, any non-matrix content URLs will be ignored. Setting this option to + * true will expose URLs that, if fetched, will leak information about the user + * to anyone who they share a room with. + * @return {?string} the avatar URL or null. + */ + public getAvatarUrl( + baseUrl: string, + width: number, + height: number, + resizeMethod: string, + allowDefault = true, + allowDirectLinks: boolean, + ): string | null { + const rawUrl = this.getMxcAvatarUrl(); + + if (!rawUrl && !allowDefault) { + return null; + } + const httpUrl = getHttpUriForMxc(baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks); + if (httpUrl) { + return httpUrl; + } + return null; + } + + /** + * get the mxc avatar url, either from a state event, or from a lazily loaded member + * @return {string} the mxc avatar url + */ + public getMxcAvatarUrl(): string | null { + if (this.events.member) { + return this.events.member.getDirectionalContent().avatar_url; + } else if (this.user) { + return this.user.avatarUrl; + } + return null; + } +} + +const MXID_PATTERN = /@.+:.+/; +const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; + +function shouldDisambiguate(selfUserId: string, displayName: string, roomState: RoomState): boolean { + 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 false; + + 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 + if (MXID_PATTERN.test(displayName)) return true; + + // 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; + + // 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: string, + displayName: string, + roomState: RoomState, + disambiguate: boolean, +): string { + 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; + + return displayName; +} + +/** + * Fires whenever any room member's name changes. + * @event module:client~MatrixClient#"RoomMember.name" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.name changed. + * @param {string?} oldName The previous name. Null if the member didn't have a + * name previously. + * @example + * matrixClient.on("RoomMember.name", function(event, member){ + * var newName = member.name; + * }); + */ + +/** + * Fires whenever any room member's membership state changes. + * @event module:client~MatrixClient#"RoomMember.membership" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.membership changed. + * @param {string?} oldMembership The previous membership state. Null if it's a + * new member. + * @example + * matrixClient.on("RoomMember.membership", function(event, member, oldMembership){ + * var newState = member.membership; + * }); + */ + +/** + * Fires whenever any room member's typing state changes. + * @event module:client~MatrixClient#"RoomMember.typing" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.typing changed. + * @example + * matrixClient.on("RoomMember.typing", function(event, member){ + * var isTyping = member.typing; + * }); + */ + +/** + * Fires whenever any room member's power level changes. + * @event module:client~MatrixClient#"RoomMember.powerLevel" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.powerLevel changed. + * @example + * matrixClient.on("RoomMember.powerLevel", function(event, member){ + * var newPowerLevel = member.powerLevel; + * var newNormPowerLevel = member.powerLevelNorm; + * }); + */ diff --git a/src/models/user.js b/src/models/user.js deleted file mode 100644 index ec1127e84..000000000 --- a/src/models/user.js +++ /dev/null @@ -1,260 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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 models/user - */ - -import * as utils from "../utils"; -import { EventEmitter } from "events"; - -/** - * Construct a new User. A User must have an ID and can optionally have extra - * information associated with it. - * @constructor - * @param {string} userId Required. The ID of this user. - * @prop {string} userId The ID of the user. - * @prop {Object} info The info object supplied in the constructor. - * @prop {string} displayName The 'displayname' of the user if known. - * @prop {string} avatarUrl The 'avatar_url' of the user if known. - * @prop {string} presence The presence enum if known. - * @prop {string} presenceStatusMsg The presence status message if known. - * @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted - * proactively with the server, or we saw a message from the user - * @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last - * received presence data for this user. We can subtract - * lastActiveAgo from this to approximate an absolute value for - * when a user was last active. - * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be - * an approximation and that the user should be seen as active 'now' - * @prop {string} _unstable_statusMessage The status message for the user, if known. This is - * different from the presenceStatusMsg in that this is not tied to - * the user's presence, and should be represented differently. - * @prop {Object} events The events describing this user. - * @prop {MatrixEvent} events.presence The m.presence event for this user. - */ -export function User(userId) { - this.userId = userId; - this.presence = "offline"; - this.presenceStatusMsg = null; - this._unstable_statusMessage = ""; - this.displayName = userId; - this.rawDisplayName = userId; - this.avatarUrl = null; - this.lastActiveAgo = 0; - this.lastPresenceTs = 0; - this.currentlyActive = false; - this.events = { - presence: null, - profile: null, - }; - this._updateModifiedTime(); -} -utils.inherits(User, EventEmitter); - -/** - * Update this User with the given presence event. May fire "User.presence", - * "User.avatarUrl" and/or "User.displayName" if this event updates this user's - * properties. - * @param {MatrixEvent} event The m.presence event. - * @fires module:client~MatrixClient#event:"User.presence" - * @fires module:client~MatrixClient#event:"User.displayName" - * @fires module:client~MatrixClient#event:"User.avatarUrl" - */ -User.prototype.setPresenceEvent = function(event) { - if (event.getType() !== "m.presence") { - return; - } - const firstFire = this.events.presence === null; - this.events.presence = event; - - const eventsToFire = []; - if (event.getContent().presence !== this.presence || firstFire) { - eventsToFire.push("User.presence"); - } - if (event.getContent().avatar_url && - event.getContent().avatar_url !== this.avatarUrl) { - eventsToFire.push("User.avatarUrl"); - } - if (event.getContent().displayname && - event.getContent().displayname !== this.displayName) { - eventsToFire.push("User.displayName"); - } - if (event.getContent().currently_active !== undefined && - event.getContent().currently_active !== this.currentlyActive) { - eventsToFire.push("User.currentlyActive"); - } - - this.presence = event.getContent().presence; - eventsToFire.push("User.lastPresenceTs"); - - if (event.getContent().status_msg) { - this.presenceStatusMsg = event.getContent().status_msg; - } - if (event.getContent().displayname) { - this.displayName = event.getContent().displayname; - } - if (event.getContent().avatar_url) { - this.avatarUrl = event.getContent().avatar_url; - } - this.lastActiveAgo = event.getContent().last_active_ago; - this.lastPresenceTs = Date.now(); - this.currentlyActive = event.getContent().currently_active; - - this._updateModifiedTime(); - - for (let i = 0; i < eventsToFire.length; i++) { - this.emit(eventsToFire[i], event, this); - } -}; - -/** - * Manually set this user's display name. No event is emitted in response to this - * as there is no underlying MatrixEvent to emit with. - * @param {string} name The new display name. - */ -User.prototype.setDisplayName = function(name) { - const oldName = this.displayName; - if (typeof name === "string") { - this.displayName = name; - } else { - this.displayName = undefined; - } - if (name !== oldName) { - this._updateModifiedTime(); - } -}; - -/** - * Manually set this user's non-disambiguated display name. No event is emitted - * in response to this as there is no underlying MatrixEvent to emit with. - * @param {string} name The new display name. - */ -User.prototype.setRawDisplayName = function(name) { - if (typeof name === "string") { - this.rawDisplayName = name; - } else { - this.rawDisplayName = undefined; - } -}; - -/** - * Manually set this user's avatar URL. No event is emitted in response to this - * as there is no underlying MatrixEvent to emit with. - * @param {string} url The new avatar URL. - */ -User.prototype.setAvatarUrl = function(url) { - const oldUrl = this.avatarUrl; - this.avatarUrl = url; - if (url !== oldUrl) { - this._updateModifiedTime(); - } -}; - -/** - * Update the last modified time to the current time. - */ -User.prototype._updateModifiedTime = function() { - this._modified = Date.now(); -}; - -/** - * Get the timestamp when this User was last updated. This timestamp is - * updated when this User receives a new Presence event which has updated a - * property on this object. It is updated before firing events. - * @return {number} The timestamp - */ -User.prototype.getLastModifiedTime = function() { - return this._modified; -}; - -/** - * Get the absolute timestamp when this User was last known active on the server. - * It is *NOT* accurate if this.currentlyActive is true. - * @return {number} The timestamp - */ -User.prototype.getLastActiveTs = function() { - return this.lastPresenceTs - this.lastActiveAgo; -}; - -/** - * Manually set the user's status message. - * @param {MatrixEvent} event The im.vector.user_status event. - * @fires module:client~MatrixClient#event:"User._unstable_statusMessage" - */ -User.prototype._unstable_updateStatusMessage = function(event) { - if (!event.getContent()) this._unstable_statusMessage = ""; - else this._unstable_statusMessage = event.getContent()["status"]; - this._updateModifiedTime(); - this.emit("User._unstable_statusMessage", this); -}; - -/** - * Fires whenever any user's lastPresenceTs changes, - * ie. whenever any presence event is received for a user. - * @event module:client~MatrixClient#"User.lastPresenceTs" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.lastPresenceTs changed. - * @example - * matrixClient.on("User.lastPresenceTs", function(event, user){ - * var newlastPresenceTs = user.lastPresenceTs; - * }); - */ - -/** - * Fires whenever any user's presence changes. - * @event module:client~MatrixClient#"User.presence" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.presence changed. - * @example - * matrixClient.on("User.presence", function(event, user){ - * var newPresence = user.presence; - * }); - */ - -/** - * Fires whenever any user's currentlyActive changes. - * @event module:client~MatrixClient#"User.currentlyActive" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.currentlyActive changed. - * @example - * matrixClient.on("User.currentlyActive", function(event, user){ - * var newCurrentlyActive = user.currentlyActive; - * }); - */ - -/** - * Fires whenever any user's display name changes. - * @event module:client~MatrixClient#"User.displayName" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.displayName changed. - * @example - * matrixClient.on("User.displayName", function(event, user){ - * var newName = user.displayName; - * }); - */ - -/** - * Fires whenever any user's avatar URL changes. - * @event module:client~MatrixClient#"User.avatarUrl" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.avatarUrl changed. - * @example - * matrixClient.on("User.avatarUrl", function(event, user){ - * var newUrl = user.avatarUrl; - * }); - */ diff --git a/src/models/user.ts b/src/models/user.ts new file mode 100644 index 000000000..da5c4d78d --- /dev/null +++ b/src/models/user.ts @@ -0,0 +1,273 @@ +/* +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. +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 models/user + */ + +import { EventEmitter } from "events"; + +import { MatrixEvent } from "./event"; + +export class User extends EventEmitter { + // eslint-disable-next-line camelcase + private modified: number; + + // XXX these should be read-only + public displayName: string; + public rawDisplayName: string; + public avatarUrl: string; + public presenceStatusMsg: string = null; + public presence = "offline"; + public lastActiveAgo = 0; + public lastPresenceTs = 0; + public currentlyActive = false; + public events: { + presence?: MatrixEvent; + profile?: MatrixEvent; + } = { + presence: null, + profile: null, + }; + public unstable_statusMessage = ""; + + /** + * Construct a new User. A User must have an ID and can optionally have extra + * information associated with it. + * @constructor + * @param {string} userId Required. The ID of this user. + * @prop {string} userId The ID of the user. + * @prop {Object} info The info object supplied in the constructor. + * @prop {string} displayName The 'displayname' of the user if known. + * @prop {string} avatarUrl The 'avatar_url' of the user if known. + * @prop {string} presence The presence enum if known. + * @prop {string} presenceStatusMsg The presence status message if known. + * @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted + * proactively with the server, or we saw a message from the user + * @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last + * received presence data for this user. We can subtract + * lastActiveAgo from this to approximate an absolute value for + * when a user was last active. + * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be + * an approximation and that the user should be seen as active 'now' + * @prop {string} unstable_statusMessage The status message for the user, if known. This is + * different from the presenceStatusMsg in that this is not tied to + * the user's presence, and should be represented differently. + * @prop {Object} events The events describing this user. + * @prop {MatrixEvent} events.presence The m.presence event for this user. + */ + constructor(public readonly userId: string) { + super(); + this.displayName = userId; + this.rawDisplayName = userId; + this.avatarUrl = null; + this.updateModifiedTime(); + } + + /** + * Update this User with the given presence event. May fire "User.presence", + * "User.avatarUrl" and/or "User.displayName" if this event updates this user's + * properties. + * @param {MatrixEvent} event The m.presence event. + * @fires module:client~MatrixClient#event:"User.presence" + * @fires module:client~MatrixClient#event:"User.displayName" + * @fires module:client~MatrixClient#event:"User.avatarUrl" + */ + public setPresenceEvent(event: MatrixEvent): void { + if (event.getType() !== "m.presence") { + return; + } + const firstFire = this.events.presence === null; + this.events.presence = event; + + const eventsToFire = []; + if (event.getContent().presence !== this.presence || firstFire) { + eventsToFire.push("User.presence"); + } + if (event.getContent().avatar_url && + event.getContent().avatar_url !== this.avatarUrl) { + eventsToFire.push("User.avatarUrl"); + } + if (event.getContent().displayname && + event.getContent().displayname !== this.displayName) { + eventsToFire.push("User.displayName"); + } + if (event.getContent().currently_active !== undefined && + event.getContent().currently_active !== this.currentlyActive) { + eventsToFire.push("User.currentlyActive"); + } + + this.presence = event.getContent().presence; + eventsToFire.push("User.lastPresenceTs"); + + if (event.getContent().status_msg) { + this.presenceStatusMsg = event.getContent().status_msg; + } + if (event.getContent().displayname) { + this.displayName = event.getContent().displayname; + } + if (event.getContent().avatar_url) { + this.avatarUrl = event.getContent().avatar_url; + } + this.lastActiveAgo = event.getContent().last_active_ago; + this.lastPresenceTs = Date.now(); + this.currentlyActive = event.getContent().currently_active; + + this.updateModifiedTime(); + + for (let i = 0; i < eventsToFire.length; i++) { + this.emit(eventsToFire[i], event, this); + } + } + + /** + * Manually set this user's display name. No event is emitted in response to this + * as there is no underlying MatrixEvent to emit with. + * @param {string} name The new display name. + */ + public setDisplayName(name: string): void { + const oldName = this.displayName; + if (typeof name === "string") { + this.displayName = name; + } else { + this.displayName = undefined; + } + if (name !== oldName) { + this.updateModifiedTime(); + } + } + + /** + * Manually set this user's non-disambiguated display name. No event is emitted + * in response to this as there is no underlying MatrixEvent to emit with. + * @param {string} name The new display name. + */ + public setRawDisplayName(name: string): void { + if (typeof name === "string") { + this.rawDisplayName = name; + } else { + this.rawDisplayName = undefined; + } + } + + /** + * Manually set this user's avatar URL. No event is emitted in response to this + * as there is no underlying MatrixEvent to emit with. + * @param {string} url The new avatar URL. + */ + public setAvatarUrl(url: string): void { + const oldUrl = this.avatarUrl; + this.avatarUrl = url; + if (url !== oldUrl) { + this.updateModifiedTime(); + } + } + + /** + * Update the last modified time to the current time. + */ + private updateModifiedTime(): void { + this.modified = Date.now(); + } + + /** + * Get the timestamp when this User was last updated. This timestamp is + * updated when this User receives a new Presence event which has updated a + * property on this object. It is updated before firing events. + * @return {number} The timestamp + */ + public getLastModifiedTime(): number { + return this.modified; + } + + /** + * Get the absolute timestamp when this User was last known active on the server. + * It is *NOT* accurate if this.currentlyActive is true. + * @return {number} The timestamp + */ + public getLastActiveTs(): number { + return this.lastPresenceTs - this.lastActiveAgo; + } + + /** + * Manually set the user's status message. + * @param {MatrixEvent} event The im.vector.user_status event. + * @fires module:client~MatrixClient#event:"User.unstable_statusMessage" + */ + // eslint-disable-next-line camelcase + public _unstable_updateStatusMessage(event: MatrixEvent): void { + if (!event.getContent()) this.unstable_statusMessage = ""; + else this.unstable_statusMessage = event.getContent()["status"]; + this.updateModifiedTime(); + this.emit("User.unstable_statusMessage", this); + } +} + +/** + * Fires whenever any user's lastPresenceTs changes, + * ie. whenever any presence event is received for a user. + * @event module:client~MatrixClient#"User.lastPresenceTs" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.lastPresenceTs changed. + * @example + * matrixClient.on("User.lastPresenceTs", function(event, user){ + * var newlastPresenceTs = user.lastPresenceTs; + * }); + */ + +/** + * Fires whenever any user's presence changes. + * @event module:client~MatrixClient#"User.presence" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.presence changed. + * @example + * matrixClient.on("User.presence", function(event, user){ + * var newPresence = user.presence; + * }); + */ + +/** + * Fires whenever any user's currentlyActive changes. + * @event module:client~MatrixClient#"User.currentlyActive" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.currentlyActive changed. + * @example + * matrixClient.on("User.currentlyActive", function(event, user){ + * var newCurrentlyActive = user.currentlyActive; + * }); + */ + +/** + * Fires whenever any user's display name changes. + * @event module:client~MatrixClient#"User.displayName" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.displayName changed. + * @example + * matrixClient.on("User.displayName", function(event, user){ + * var newName = user.displayName; + * }); + */ + +/** + * Fires whenever any user's avatar URL changes. + * @event module:client~MatrixClient#"User.avatarUrl" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.avatarUrl changed. + * @example + * matrixClient.on("User.avatarUrl", function(event, user){ + * var newUrl = user.avatarUrl; + * }); + */ From bfea8824163951b01804773a644f263ade0529c8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:04:04 +0100 Subject: [PATCH 061/124] Convert MatrixEvent to TS --- src/models/{event.js => event.ts} | 681 +++++++++++++++++------------- 1 file changed, 379 insertions(+), 302 deletions(-) rename src/models/{event.js => event.ts} (70%) diff --git a/src/models/event.js b/src/models/event.ts similarity index 70% rename from src/models/event.js rename to src/models/event.ts index 1b3bec755..17c6be3ba 100644 --- a/src/models/event.js +++ b/src/models/event.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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. @@ -22,153 +21,168 @@ limitations under the License. */ import { EventEmitter } from 'events'; -import * as utils from '../utils'; + import { logger } from '../logger'; +import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; +import { EventType, RelationType } from "../@types/event"; +import { Crypto } from "../crypto"; /** * Enum for event statuses. * @readonly * @enum {string} */ -export const EventStatus = { +export enum EventStatus { /** The event was not sent and will no longer be retried. */ - NOT_SENT: "not_sent", + NOT_SENT = "not_sent", /** The message is being encrypted */ - ENCRYPTING: "encrypting", + ENCRYPTING = "encrypting", /** The event is in the process of being sent. */ - SENDING: "sending", + SENDING = "sending", + /** The event is in a queue waiting to be sent. */ - QUEUED: "queued", - /** The event has been sent to the server, but we have not yet received the - * echo. */ - SENT: "sent", + QUEUED = "queued", + + /** The event has been sent to the server, but we have not yet received the echo. */ + SENT = "sent", /** The event was cancelled before it was successfully sent. */ - CANCELLED: "cancelled", -}; + CANCELLED = "cancelled", +} -const interns = {}; -function intern(str) { +const interns: Record = {}; +function intern(str: string): string { if (!interns[str]) { interns[str] = str; } return interns[str]; } -/** - * Construct a Matrix Event object - * @constructor - * - * @param {Object} event The raw event to be wrapped in this DAO - * - * @prop {Object} event The raw (possibly encrypted) event. Do not access - * this property directly unless you absolutely have to. Prefer the getter - * methods defined on this class. Using the getter methods shields your app - * from changes to event JSON between Matrix versions. - * - * @prop {RoomMember} sender The room member who sent this event, or null e.g. - * this is a presence event. This is only guaranteed to be set for events that - * appear in a timeline, ie. do not guarantee that it will be set on state - * events. - * @prop {RoomMember} target The room member who is the target of this event, e.g. - * the invitee, the person being banned, etc. - * @prop {EventStatus} status The sending status of the event. - * @prop {Error} error most recent error associated with sending the event, if any - * @prop {boolean} forwardLooking True if this event is 'forward looking', meaning - * that getDirectionalContent() will return event.content and not event.prev_content. - * Default: true. This property is experimental and may change. - */ -export const MatrixEvent = function( - event, -) { - // intern the values of matrix events to force share strings and reduce the - // amount of needless string duplication. This can save moderate amounts of - // memory (~10% on a 350MB heap). - // 'membership' at the event level (rather than the content level) is a legacy - // field that Element never otherwise looks at, but it will still take up a lot - // of space if we don't intern it. - ["state_key", "type", "sender", "room_id", "membership"].forEach((prop) => { - if (!event[prop]) { - return; - } - event[prop] = intern(event[prop]); - }); +/* eslint-disable camelcase */ +interface IContent { + [key: string]: any; + msgtype?: string; + membership?: string; + avatar_url?: string; + displayname?: string; + "m.relates_to"?: IEventRelation; +} - ["membership", "avatar_url", "displayname"].forEach((prop) => { - if (!event.content || !event.content[prop]) { - return; - } - event.content[prop] = intern(event.content[prop]); - }); +interface IUnsigned { + age?: number; + prev_sender?: string; + prev_content?: IContent; + redacted_because?: IEvent; +} - ["rel_type"].forEach((prop) => { - if ( - !event.content || - !event.content["m.relates_to"] || - !event.content["m.relates_to"][prop] - ) { - return; - } - event.content["m.relates_to"][prop] = intern(event.content["m.relates_to"][prop]); - }); +interface IEvent { + event_id: string; + type: string; + content: IContent; + sender: string; + room_id: string; + origin_server_ts: number; + txn_id?: string; + state_key?: string; + membership?: string; + unsigned?: IUnsigned; + redacts?: string; - this.event = event || {}; + // v1 legacy fields + user_id?: string; + prev_content?: IContent; + age?: number; +} - this.sender = null; - this.target = null; - this.status = null; - this.error = null; - this.forwardLooking = true; - this._pushActions = null; - this._replacingEvent = null; - this._localRedactionEvent = null; - this._isCancelled = false; +interface IAggregatedRelation { + origin_server_ts: number; + event_id?: string; + sender?: string; + type?: string; + count?: number; + key?: string; +} - this._clearEvent = {}; +interface IEventRelation { + rel_type: string; + event_id: string; + key?: string; +} + +interface IDecryptionResult { + clearEvent: { + room_id?: string; + type: string, + content: IContent, + unsigned?: IUnsigned, + }; + forwardingCurve25519KeyChain?: string[]; + senderCurve25519Key?: string; + claimedEd25519Key?: string; + untrusted?: boolean; +} +/* eslint-enable camelcase */ + +interface IClearEvent { + type: string; + content: Omit; + unsigned?: IUnsigned; +} + +interface IKeyRequestRecipient { + userId: string; + deviceId: "*" | string; +} + +export interface IDecryptOptions { + emit?: boolean; + isRetry?: boolean; +} + +export class MatrixEvent extends EventEmitter { + private pushActions: object = null; + private _replacingEvent: MatrixEvent = null; + private _localRedactionEvent: MatrixEvent = null; + private _isCancelled = false; + private clearEvent: Partial = {}; /* curve25519 key which we believe belongs to the sender of the event. See * getSenderKey() */ - this._senderCurve25519Key = null; + private senderCurve25519Key: string = null; /* ed25519 key which the sender of this event (for olm) or the creator of * the megolm session (for megolm) claims to own. See getClaimedEd25519Key() */ - this._claimedEd25519Key = null; + private claimedEd25519Key: string = null; /* curve25519 keys of devices involved in telling us about the - * _senderCurve25519Key and _claimedEd25519Key. + * senderCurve25519Key and claimedEd25519Key. * See getForwardingCurve25519KeyChain(). */ - this._forwardingCurve25519KeyChain = []; + private forwardingCurve25519KeyChain: string[] = []; /* where the decryption key is untrusted */ - this._untrusted = null; + private untrusted: boolean = null; /* if we have a process decrypting this event, a Promise which resolves * when it is finished. Normally null. */ - this._decryptionPromise = null; + private _decryptionPromise: Promise = null; /* flag to indicate if we should retry decrypting this event after the * first attempt (eg, we have received new data which means that a second * attempt may succeed) */ - this._retryDecryption = false; - - /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event, - * `Crypto` will set this the `VerificationRequest` for the event - * so it can be easily accessed from the timeline. - */ - this.verificationRequest = null; + private retryDecryption = false; /* The txnId with which this event was sent if it was during this session, - allows for a unique ID which does not change when the event comes back down sync. + * allows for a unique ID which does not change when the event comes back down sync. */ - this._txnId = event.txn_id || null; + private txnId: string = null; /* Set an approximate timestamp for the event relative the local clock. * This will inherently be approximate because it doesn't take into account @@ -176,36 +190,97 @@ export const MatrixEvent = function( * it to us and the time we're now constructing this event, but that's better * than assuming the local clock is in sync with the origin HS's clock. */ - this._localTimestamp = Date.now() - this.getAge(); -}; -utils.inherits(MatrixEvent, EventEmitter); + private readonly localTimestamp: number; + + // XXX: these should be read-only + public sender = null; + public target = null; + public status: EventStatus = null; + public error = null; + public forwardLooking = true; + + /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event, + * `Crypto` will set this the `VerificationRequest` for the event + * so it can be easily accessed from the timeline. + */ + public verificationRequest = null; + + /** + * Construct a Matrix Event object + * @constructor + * + * @param {Object} event The raw event to be wrapped in this DAO + * + * @prop {Object} event The raw (possibly encrypted) event. Do not access + * this property directly unless you absolutely have to. Prefer the getter + * methods defined on this class. Using the getter methods shields your app + * from changes to event JSON between Matrix versions. + * + * @prop {RoomMember} sender The room member who sent this event, or null e.g. + * this is a presence event. This is only guaranteed to be set for events that + * appear in a timeline, ie. do not guarantee that it will be set on state + * events. + * @prop {RoomMember} target The room member who is the target of this event, e.g. + * the invitee, the person being banned, etc. + * @prop {EventStatus} status The sending status of the event. + * @prop {Error} error most recent error associated with sending the event, if any + * @prop {boolean} forwardLooking True if this event is 'forward looking', meaning + * that getDirectionalContent() will return event.content and not event.prev_content. + * Default: true. This property is experimental and may change. + */ + constructor(public event: Partial = {}) { + super(); + + // intern the values of matrix events to force share strings and reduce the + // amount of needless string duplication. This can save moderate amounts of + // memory (~10% on a 350MB heap). + // 'membership' at the event level (rather than the content level) is a legacy + // field that Element never otherwise looks at, but it will still take up a lot + // of space if we don't intern it. + ["state_key", "type", "sender", "room_id", "membership"].forEach((prop) => { + if (typeof event[prop] !== "string") return; + event[prop] = intern(event[prop]); + }); + + ["membership", "avatar_url", "displayname"].forEach((prop) => { + if (typeof event.content?.[prop] !== "string") return; + event.content[prop] = intern(event.content[prop]); + }); + + ["rel_type"].forEach((prop) => { + if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return; + event.content["m.relates_to"][prop] = intern(event.content["m.relates_to"][prop]); + }); + + this.txnId = event.txn_id || null; + this.localTimestamp = Date.now() - this.getAge(); + } -utils.extend(MatrixEvent.prototype, { /** * Get the event_id for this event. * @return {string} The event ID, e.g. $143350589368169JsLZx:localhost * */ - getId: function() { + public getId(): string { return this.event.event_id; - }, + } /** * Get the user_id for this event. * @return {string} The user ID, e.g. @alice:matrix.org */ - getSender: function() { + public getSender(): string { return this.event.sender || this.event.user_id; // v2 / v1 - }, + } /** * Get the (decrypted, if necessary) type of event. * * @return {string} The event type, e.g. m.room.message */ - getType: function() { - return this._clearEvent.type || this.event.type; - }, + public getType(): EventType | string { + return this.clearEvent.type || this.event.type; + } /** * Get the (possibly encrypted) type of the event that will be sent to the @@ -213,9 +288,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string} The event type. */ - getWireType: function() { + public getWireType(): EventType | string { return this.event.type; - }, + } /** * Get the room_id for this event. This will return undefined @@ -223,25 +298,25 @@ utils.extend(MatrixEvent.prototype, { * @return {string} The room ID, e.g. !cURbafjkfsMDVwdRDQ:matrix.org * */ - getRoomId: function() { + public getRoomId(): string { return this.event.room_id; - }, + } /** * Get the timestamp of this event. * @return {Number} The event timestamp, e.g. 1433502692297 */ - getTs: function() { + public getTs(): number { return this.event.origin_server_ts; - }, + } /** * Get the timestamp of this event, as a Date object. * @return {Date} The event date, e.g. new Date(1433502692297) */ - getDate: function() { + public getDate(): Date | null { return this.event.origin_server_ts ? new Date(this.event.origin_server_ts) : null; - }, + } /** * Get the (decrypted, if necessary) event content JSON, even if the event @@ -249,12 +324,12 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} The event content JSON, or an empty object. */ - getOriginalContent: function() { + public getOriginalContent(): IContent { if (this._localRedactionEvent) { return {}; } - return this._clearEvent.content || this.event.content || {}; - }, + return this.clearEvent.content || this.event.content || {}; + } /** * Get the (decrypted, if necessary) event content JSON, @@ -263,7 +338,7 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} The event content JSON, or an empty object. */ - getContent: function() { + public getContent(): IContent { if (this._localRedactionEvent) { return {}; } else if (this._replacingEvent) { @@ -271,7 +346,7 @@ utils.extend(MatrixEvent.prototype, { } else { return this.getOriginalContent(); } - }, + } /** * Get the (possibly encrypted) event content JSON that will be sent to the @@ -279,19 +354,19 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} The event content JSON, or an empty object. */ - getWireContent: function() { + public getWireContent(): IContent { return this.event.content || {}; - }, + } /** * Get the previous event content JSON. This will only return something for * state events which exist in the timeline. * @return {Object} The previous event content JSON, or an empty object. */ - getPrevContent: function() { + public getPrevContent(): IContent { // v2 then v1 then default return this.getUnsigned().prev_content || this.event.prev_content || {}; - }, + } /** * Get either 'content' or 'prev_content' depending on if this event is @@ -302,9 +377,9 @@ utils.extend(MatrixEvent.prototype, { * @return {Object} event.content if this event is forward-looking, else * event.prev_content. */ - getDirectionalContent: function() { + public getDirectionalContent(): IContent { return this.forwardLooking ? this.getContent() : this.getPrevContent(); - }, + } /** * Get the age of this event. This represents the age of the event when the @@ -312,9 +387,9 @@ utils.extend(MatrixEvent.prototype, { * function was called. * @return {Number} The age of this event in milliseconds. */ - getAge: function() { + public getAge(): number { return this.getUnsigned().age || this.event.age; // v2 / v1 - }, + } /** * Get the age of the event when this function was called. @@ -322,26 +397,26 @@ utils.extend(MatrixEvent.prototype, { * had the event. * @return {Number} The age of this event in milliseconds. */ - getLocalAge: function() { - return Date.now() - this._localTimestamp; - }, + public getLocalAge(): number { + return Date.now() - this.localTimestamp; + } /** * Get the event state_key if it has one. This will return undefined * for message events. * @return {string} The event's state_key. */ - getStateKey: function() { + public getStateKey(): string | undefined { return this.event.state_key; - }, + } /** * Check if this event is a state event. * @return {boolean} True if this is a state event. */ - isState: function() { + public isState(): boolean { return this.event.state_key !== undefined; - }, + } /** * Replace the content of this event with encrypted versions. @@ -349,10 +424,10 @@ utils.extend(MatrixEvent.prototype, { * * @internal * - * @param {string} crypto_type type of the encrypted event - typically + * @param {string} cryptoType type of the encrypted event - typically * "m.room.encrypted" * - * @param {object} crypto_content raw 'content' for the encrypted event. + * @param {object} cryptoContent raw 'content' for the encrypted event. * * @param {string} senderCurve25519Key curve25519 key to record for the * sender of this event. @@ -362,28 +437,35 @@ utils.extend(MatrixEvent.prototype, { * sender if this event. * See {@link module:models/event.MatrixEvent#getClaimedEd25519Key} */ - makeEncrypted: function( - crypto_type, crypto_content, senderCurve25519Key, claimedEd25519Key, - ) { + public makeEncrypted( + cryptoType: string, + cryptoContent: object, + senderCurve25519Key: string, + claimedEd25519Key: string, + ): void { // keep the plain-text data for 'view source' - this._clearEvent = { + this.clearEvent = { type: this.event.type, content: this.event.content, }; - this.event.type = crypto_type; - this.event.content = crypto_content; - this._senderCurve25519Key = senderCurve25519Key; - this._claimedEd25519Key = claimedEd25519Key; - }, + this.event.type = cryptoType; + this.event.content = cryptoContent; + this.senderCurve25519Key = senderCurve25519Key; + this.claimedEd25519Key = claimedEd25519Key; + } /** * Check if this event is currently being decrypted. * * @return {boolean} True if this event is currently being decrypted, else false. */ - isBeingDecrypted: function() { + public isBeingDecrypted(): boolean { return this._decryptionPromise != null; - }, + } + + public getDecryptionPromise(): Promise { + return this._decryptionPromise; + } /** * Check if this event is an encrypted event which we failed to decrypt @@ -393,16 +475,13 @@ utils.extend(MatrixEvent.prototype, { * @return {boolean} True if this event is an encrypted event which we * couldn't decrypt. */ - isDecryptionFailure: function() { - return this._clearEvent && this._clearEvent.content && - this._clearEvent.content.msgtype === "m.bad.encrypted"; - }, + public isDecryptionFailure(): boolean { + return this.clearEvent?.content?.msgtype === "m.bad.encrypted"; + } - shouldAttemptDecryption: function() { - return this.isEncrypted() - && !this.isBeingDecrypted() - && this.getClearContent() === null; - }, + public shouldAttemptDecryption() { + return this.isEncrypted() && !this.isBeingDecrypted() && this.getClearContent() === null; + } /** * Start the process of trying to decrypt this event. @@ -419,7 +498,7 @@ utils.extend(MatrixEvent.prototype, { * @returns {Promise} promise which resolves (to undefined) when the decryption * attempt is completed. */ - attemptDecryption: async function(crypto, options = {}) { + public async attemptDecryption(crypto: Crypto, options: IDecryptOptions = {}): Promise { // For backwards compatibility purposes // The function signature used to be attemptDecryption(crypto, isRetry) if (typeof options === "boolean") { @@ -434,8 +513,8 @@ utils.extend(MatrixEvent.prototype, { } if ( - this._clearEvent && this._clearEvent.content && - this._clearEvent.content.msgtype !== "m.bad.encrypted" + this.clearEvent && this.clearEvent.content && + this.clearEvent.content.msgtype !== "m.bad.encrypted" ) { // we may want to just ignore this? let's start with rejecting it. throw new Error( @@ -453,13 +532,13 @@ utils.extend(MatrixEvent.prototype, { logger.log( `Event ${this.getId()} already being decrypted; queueing a retry`, ); - this._retryDecryption = true; + this.retryDecryption = true; return this._decryptionPromise; } - this._decryptionPromise = this._decryptionLoop(crypto, options); + this._decryptionPromise = this.decryptionLoop(crypto, options); return this._decryptionPromise; - }, + } /** * Cancel any room key request for this event and resend another. @@ -469,7 +548,7 @@ utils.extend(MatrixEvent.prototype, { * * @returns {Promise} a promise that resolves when the request is queued */ - cancelAndResendKeyRequest: function(crypto, userId) { + public cancelAndResendKeyRequest(crypto: Crypto, userId: string): Promise { const wireContent = this.getWireContent(); return crypto.requestRoomKey({ algorithm: wireContent.algorithm, @@ -477,7 +556,7 @@ utils.extend(MatrixEvent.prototype, { session_id: wireContent.session_id, sender_key: wireContent.sender_key, }, this.getKeyRequestRecipients(userId), true); - }, + } /** * Calculate the recipients for keyshare requests. @@ -486,7 +565,7 @@ utils.extend(MatrixEvent.prototype, { * * @returns {Array} array of recipients */ - getKeyRequestRecipients: function(userId) { + public getKeyRequestRecipients(userId: string): IKeyRequestRecipient[] { // send the request to all of our own devices, and the // original sending device if it wasn't us. const wireContent = this.getWireContent(); @@ -500,23 +579,24 @@ utils.extend(MatrixEvent.prototype, { }); } return recipients; - }, + } - _decryptionLoop: async function(crypto, options = {}) { + private async decryptionLoop(crypto: Crypto, options: IDecryptOptions = {}): Promise { // make sure that this method never runs completely synchronously. // (doing so would mean that we would clear _decryptionPromise *before* // it is set in attemptDecryption - and hence end up with a stuck // `_decryptionPromise`). await Promise.resolve(); + // eslint-disable-next-line no-constant-condition while (true) { - this._retryDecryption = false; + this.retryDecryption = false; let res; let err; try { if (!crypto) { - res = this._badEncryptedMessage("Encryption not enabled"); + res = this.badEncryptedMessage("Encryption not enabled"); } else { res = await crypto.decryptEvent(this); if (options.isRetry === true) { @@ -533,7 +613,7 @@ utils.extend(MatrixEvent.prototype, { `(id=${this.getId()}): ${e.stack || e}`, ); this._decryptionPromise = null; - this._retryDecryption = false; + this.retryDecryption = false; return; } @@ -545,15 +625,15 @@ utils.extend(MatrixEvent.prototype, { // event loop as `_decryptionPromise = null` below - otherwise we // risk a race: // - // * A: we check _retryDecryption here and see that it is + // * A: we check retryDecryption here and see that it is // false // * B: we get a second call to attemptDecryption, which sees // that _decryptionPromise is set so sets - // _retryDecryption + // retryDecryption // * A: we continue below, clear _decryptionPromise, and // never do the retry. // - if (this._retryDecryption) { + if (this.retryDecryption) { // decryption error, but we have a retry queued. logger.log( `Got error decrypting event (id=${this.getId()}: ` + @@ -568,7 +648,7 @@ utils.extend(MatrixEvent.prototype, { `Error decrypting event (id=${this.getId()}): ${e.detailedString}`, ); - res = this._badEncryptedMessage(e.message); + res = this.badEncryptedMessage(e.message); } // at this point, we've either successfully decrypted the event, or have given up @@ -579,11 +659,11 @@ utils.extend(MatrixEvent.prototype, { // otherwise the app will be confused to see `isBeingDecrypted` still set when // there isn't an `Event.decrypted` on the way. // - // see also notes on _retryDecryption above. + // see also notes on retryDecryption above. // this._decryptionPromise = null; - this._retryDecryption = false; - this._setClearData(res); + this.retryDecryption = false; + this.setClearData(res); // Before we emit the event, clear the push actions so that they can be recalculated // by relevant code. We do this because the clear event has now changed, making it @@ -599,9 +679,9 @@ utils.extend(MatrixEvent.prototype, { return; } - }, + } - _badEncryptedMessage: function(reason) { + private badEncryptedMessage(reason: string): IDecryptionResult { return { clearEvent: { type: "m.room.message", @@ -611,7 +691,7 @@ utils.extend(MatrixEvent.prototype, { }, }, }; - }, + } /** * Update the cleartext data on this event. @@ -625,16 +705,16 @@ utils.extend(MatrixEvent.prototype, { * @param {module:crypto~EventDecryptionResult} decryptionResult * the decryption result, including the plaintext and some key info */ - _setClearData: function(decryptionResult) { - this._clearEvent = decryptionResult.clearEvent; - this._senderCurve25519Key = + private setClearData(decryptionResult: IDecryptionResult): void { + this.clearEvent = decryptionResult.clearEvent; + this.senderCurve25519Key = decryptionResult.senderCurve25519Key || null; - this._claimedEd25519Key = + this.claimedEd25519Key = decryptionResult.claimedEd25519Key || null; - this._forwardingCurve25519KeyChain = + this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || []; - this._untrusted = decryptionResult.untrusted || false; - }, + this.untrusted = decryptionResult.untrusted || false; + } /** * Gets the cleartext content for this event. If the event is not encrypted, @@ -642,18 +722,18 @@ utils.extend(MatrixEvent.prototype, { * * @returns {Object} The cleartext (decrypted) content for the event */ - getClearContent: function() { - const ev = this._clearEvent; + public getClearContent(): IContent | null { + const ev = this.clearEvent; return ev && ev.content ? ev.content : null; - }, + } /** * Check if the event is encrypted. * @return {boolean} True if this event is encrypted. */ - isEncrypted: function() { + public isEncrypted(): boolean { return !this.isState() && this.event.type === "m.room.encrypted"; - }, + } /** * The curve25519 key for the device that we think sent this event @@ -668,9 +748,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string} */ - getSenderKey: function() { - return this._senderCurve25519Key; - }, + public getSenderKey(): string | null { + return this.senderCurve25519Key; + } /** * The additional keys the sender of this encrypted event claims to possess. @@ -679,11 +759,11 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} */ - getKeysClaimed: function() { + public getKeysClaimed(): Record<"ed25519", string> { return { - ed25519: this._claimedEd25519Key, + ed25519: this.claimedEd25519Key, }; - }, + } /** * Get the ed25519 the sender of this event claims to own. @@ -702,9 +782,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string} */ - getClaimedEd25519Key: function() { - return this._claimedEd25519Key; - }, + public getClaimedEd25519Key(): string | null { + return this.claimedEd25519Key; + } /** * Get the curve25519 keys of the devices which were involved in telling us @@ -720,9 +800,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string[]} base64-encoded curve25519 keys, from oldest to newest. */ - getForwardingCurve25519KeyChain: function() { - return this._forwardingCurve25519KeyChain; - }, + public getForwardingCurve25519KeyChain(): string[] { + return this.forwardingCurve25519KeyChain; + } /** * Whether the decryption key was obtained from an untrusted source. If so, @@ -730,51 +810,49 @@ utils.extend(MatrixEvent.prototype, { * * @return {boolean} */ - isKeySourceUntrusted: function() { - return this._untrusted; - }, + public isKeySourceUntrusted(): boolean { + return this.untrusted; + } - getUnsigned: function() { + public getUnsigned(): IUnsigned { return this.event.unsigned || {}; - }, + } - unmarkLocallyRedacted: function() { + public unmarkLocallyRedacted(): boolean { const value = this._localRedactionEvent; this._localRedactionEvent = null; if (this.event.unsigned) { this.event.unsigned.redacted_because = null; } return !!value; - }, + } - markLocallyRedacted: function(redactionEvent) { - if (this._localRedactionEvent) { - return; - } + public markLocallyRedacted(redactionEvent: MatrixEvent): void { + if (this._localRedactionEvent) return; this.emit("Event.beforeRedaction", this, redactionEvent); this._localRedactionEvent = redactionEvent; if (!this.event.unsigned) { this.event.unsigned = {}; } - this.event.unsigned.redacted_because = redactionEvent.event; - }, + this.event.unsigned.redacted_because = redactionEvent.event as IEvent; + } /** * Update the content of an event in the same way it would be by the server * if it were redacted before it was sent to us * - * @param {module:models/event.MatrixEvent} redaction_event + * @param {module:models/event.MatrixEvent} redactionEvent * event causing the redaction */ - makeRedacted: function(redaction_event) { + public makeRedacted(redactionEvent: MatrixEvent): void { // quick sanity-check - if (!redaction_event.event) { - throw new Error("invalid redaction_event in makeRedacted"); + if (!redactionEvent.event) { + throw new Error("invalid redactionEvent in makeRedacted"); } this._localRedactionEvent = null; - this.emit("Event.beforeRedaction", this, redaction_event); + this.emit("Event.beforeRedaction", this, redactionEvent); this._replacingEvent = null; // we attempt to replicate what we would see from the server if @@ -786,19 +864,19 @@ utils.extend(MatrixEvent.prototype, { if (!this.event.unsigned) { this.event.unsigned = {}; } - this.event.unsigned.redacted_because = redaction_event.event; + this.event.unsigned.redacted_because = redactionEvent.event as IEvent; let key; for (key in this.event) { if (!this.event.hasOwnProperty(key)) { continue; } - if (!_REDACT_KEEP_KEY_MAP[key]) { + if (!REDACT_KEEP_KEYS.has(key)) { delete this.event[key]; } } - const keeps = _REDACT_KEEP_CONTENT_MAP[this.getType()] || {}; + const keeps = REDACT_KEEP_CONTENT_MAP[this.getType()] || {}; const content = this.getContent(); for (key in content) { if (!content.hasOwnProperty(key)) { @@ -808,25 +886,25 @@ utils.extend(MatrixEvent.prototype, { delete content[key]; } } - }, + } /** * Check if this event has been redacted * * @return {boolean} True if this event has been redacted */ - isRedacted: function() { + public isRedacted(): boolean { return Boolean(this.getUnsigned().redacted_because); - }, + } /** * Check if this event is a redaction of another event * * @return {boolean} True if this event is a redaction */ - isRedaction: function() { + public isRedaction(): boolean { return this.getType() === "m.room.redaction"; - }, + } /** * Get the (decrypted, if necessary) redaction event JSON @@ -834,41 +912,41 @@ utils.extend(MatrixEvent.prototype, { * * @returns {object} The redaction event JSON, or an empty object */ - getRedactionEvent: function() { + public getRedactionEvent(): object | null { if (!this.isRedacted()) return null; - if (this._clearEvent.unsigned) { - return this._clearEvent.unsigned.redacted_because; + if (this.clearEvent.unsigned) { + return this.clearEvent.unsigned.redacted_because; } else if (this.event.unsigned.redacted_because) { return this.event.unsigned.redacted_because; } else { return {}; } - }, + } /** * Get the push actions, if known, for this event * * @return {?Object} push actions */ - getPushActions: function() { - return this._pushActions; - }, + public getPushActions(): object | null { + return this.pushActions; + } /** * Set the push actions for this event. * * @param {Object} pushActions push actions */ - setPushActions: function(pushActions) { - this._pushActions = pushActions; - }, + public setPushActions(pushActions: object): void { + this.pushActions = pushActions; + } /** * Replace the `event` property and recalculate any properties based on it. * @param {Object} event the object to assign to the `event` property */ - handleRemoteEcho: function(event) { + public handleRemoteEcho(event: object): void { const oldUnsigned = this.getUnsigned(); const oldId = this.getId(); this.event = event; @@ -889,7 +967,7 @@ utils.extend(MatrixEvent.prototype, { // emit the event if it changed this.emit("Event.localEventIdReplaced", this); } - }, + } /** * Whether the event is in any phase of sending, send failure, waiting for @@ -897,24 +975,24 @@ utils.extend(MatrixEvent.prototype, { * * @return {boolean} */ - isSending() { + public isSending(): boolean { return !!this.status; - }, + } /** * Update the event's sending status and emit an event as well. * * @param {String} status The new status */ - setStatus(status) { + public setStatus(status: EventStatus): void { this.status = status; this.emit("Event.status", this, status); - }, + } - replaceLocalEventId(eventId) { + public replaceLocalEventId(eventId: string): void { this.event.event_id = eventId; this.emit("Event.localEventIdReplaced", this); - }, + } /** * Get whether the event is a relation event, and of a given type if @@ -924,26 +1002,26 @@ utils.extend(MatrixEvent.prototype, { * given type * @return {boolean} */ - isRelation(relType = undefined) { + public isRelation(relType: string = undefined): boolean { // Relation info is lifted out of the encrypted content when sent to // encrypted rooms, so we have to check `getWireContent` for this. const content = this.getWireContent(); const relation = content && content["m.relates_to"]; return relation && relation.rel_type && relation.event_id && ((relType && relation.rel_type === relType) || !relType); - }, + } /** * Get relation info for the event, if any. * * @return {Object} */ - getRelation() { + public getRelation(): IEventRelation | null { if (!this.isRelation()) { return null; } return this.getWireContent()["m.relates_to"]; - }, + } /** * Set an event that replaces the content of this event, through an m.replace relation. @@ -952,7 +1030,7 @@ utils.extend(MatrixEvent.prototype, { * * @param {MatrixEvent?} newEvent the event with the replacing content, if any. */ - makeReplaced(newEvent) { + public makeReplaced(newEvent?: MatrixEvent): void { // don't allow redacted events to be replaced. // if newEvent is null we allow to go through though, // as with local redaction, the replacing event might get @@ -964,44 +1042,44 @@ utils.extend(MatrixEvent.prototype, { this._replacingEvent = newEvent; this.emit("Event.replaced", this); } - }, + } /** * Returns the status of any associated edit or redaction - * (not for reactions/annotations as their local echo doesn't affect the orignal event), + * (not for reactions/annotations as their local echo doesn't affect the original event), * or else the status of the event. * * @return {EventStatus} */ - getAssociatedStatus() { + public getAssociatedStatus(): EventStatus | undefined { if (this._replacingEvent) { return this._replacingEvent.status; } else if (this._localRedactionEvent) { return this._localRedactionEvent.status; } return this.status; - }, + } - getServerAggregatedRelation(relType) { + public getServerAggregatedRelation(relType: RelationType): IAggregatedRelation { const relations = this.getUnsigned()["m.relations"]; if (relations) { return relations[relType]; } - }, + } /** * Returns the event ID of the event replacing the content of this event, if any. * * @return {string?} */ - replacingEventId() { - const replaceRelation = this.getServerAggregatedRelation("m.replace"); + public replacingEventId(): string | undefined { + const replaceRelation = this.getServerAggregatedRelation(RelationType.Replace); if (replaceRelation) { return replaceRelation.event_id; } else if (this._replacingEvent) { return this._replacingEvent.getId(); } - }, + } /** * Returns the event replacing the content of this event, if any. @@ -1010,17 +1088,17 @@ utils.extend(MatrixEvent.prototype, { * * @return {MatrixEvent?} */ - replacingEvent() { + public replacingEvent(): MatrixEvent | undefined { return this._replacingEvent; - }, + } /** * Returns the origin_server_ts of the event replacing the content of this event, if any. * * @return {Date?} */ - replacingEventDate() { - const replaceRelation = this.getServerAggregatedRelation("m.replace"); + public replacingEventDate(): Date | undefined { + const replaceRelation = this.getServerAggregatedRelation(RelationType.Replace); if (replaceRelation) { const ts = replaceRelation.origin_server_ts; if (Number.isFinite(ts)) { @@ -1029,38 +1107,38 @@ utils.extend(MatrixEvent.prototype, { } else if (this._replacingEvent) { return this._replacingEvent.getDate(); } - }, + } /** * Returns the event that wants to redact this event, but hasn't been sent yet. * @return {MatrixEvent} the event */ - localRedactionEvent() { + public localRedactionEvent(): MatrixEvent | undefined { return this._localRedactionEvent; - }, + } /** * For relations and redactions, returns the event_id this event is referring to. * * @return {string?} */ - getAssociatedId() { + public getAssociatedId(): string | undefined { const relation = this.getRelation(); if (relation) { return relation.event_id; } else if (this.isRedaction()) { return this.event.redacts; } - }, + } /** * Checks if this event is associated with another event. See `getAssociatedId`. * - * @return {bool} + * @return {boolean} */ - hasAssocation() { + public hasAssocation(): boolean { return !!this.getAssociatedId(); - }, + } /** * Update the related id with a new one. @@ -1070,14 +1148,14 @@ utils.extend(MatrixEvent.prototype, { * * @param {string} eventId the new event id */ - updateAssociatedId(eventId) { + public updateAssociatedId(eventId: string): void { const relation = this.getRelation(); if (relation) { relation.event_id = eventId; } else if (this.isRedaction()) { this.event.redacts = eventId; } - }, + } /** * Flags an event as cancelled due to future conditions. For example, a verification @@ -1085,18 +1163,18 @@ utils.extend(MatrixEvent.prototype, { * listeners that a cancellation event is coming down the same pipe shortly. * @param {boolean} cancelled Whether the event is to be cancelled or not. */ - flagCancelled(cancelled = true) { + public flagCancelled(cancelled = true): void { this._isCancelled = cancelled; - }, + } /** * Gets whether or not the event is flagged as cancelled. See flagCancelled() for * more information. * @returns {boolean} True if the event is cancelled, false otherwise. */ - isCancelled() { + isCancelled(): boolean { return this._isCancelled; - }, + } /** * Summarise the event as JSON for debugging. If encrypted, include both the @@ -1106,8 +1184,8 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} */ - toJSON() { - const event = { + public toJSON(): object { + const event: any = { type: this.getType(), sender: this.getSender(), content: this.getContent(), @@ -1130,22 +1208,22 @@ utils.extend(MatrixEvent.prototype, { decrypted: event, encrypted: this.event, }; - }, + } - setVerificationRequest: function(request) { + public setVerificationRequest(request: VerificationRequest): void { this.verificationRequest = request; - }, + } - setTxnId(txnId) { - this._txnId = txnId; - }, + public setTxnId(txnId: string): void { + this.txnId = txnId; + } - getTxnId() { - return this._txnId; - }, -}); + public getTxnId(): string | undefined { + return this.txnId; + } +} -/* _REDACT_KEEP_KEY_MAP gives the keys we keep when an event is redacted +/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted * * This is specified here: * http://matrix.org/speculator/spec/HEAD/client_server/latest.html#redactions @@ -1154,22 +1232,21 @@ utils.extend(MatrixEvent.prototype, { * - We keep 'unsigned' since that is created by the local server * - We keep user_id for backwards-compat with v1 */ -const _REDACT_KEEP_KEY_MAP = [ +const REDACT_KEEP_KEYS = new Set([ 'event_id', 'type', 'room_id', 'user_id', 'sender', 'state_key', 'prev_state', 'content', 'unsigned', 'origin_server_ts', -].reduce(function(ret, val) { - ret[val] = 1; return ret; -}, {}); +]); // a map from event type to the .content keys we keep when an event is redacted -const _REDACT_KEEP_CONTENT_MAP = { +const REDACT_KEEP_CONTENT_MAP = { 'm.room.member': { 'membership': 1 }, 'm.room.create': { 'creator': 1 }, 'm.room.join_rules': { 'join_rule': 1 }, - 'm.room.power_levels': { 'ban': 1, 'events': 1, 'events_default': 1, - 'kick': 1, 'redact': 1, 'state_default': 1, - 'users': 1, 'users_default': 1, - }, + 'm.room.power_levels': { + 'ban': 1, 'events': 1, 'events_default': 1, + 'kick': 1, 'redact': 1, 'state_default': 1, + 'users': 1, 'users_default': 1, + }, 'm.room.aliases': { 'aliases': 1 }, }; From 50a973409a13ff1c9112cd1f5dab636a4be7ab0b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:06:03 +0100 Subject: [PATCH 062/124] Typescript fixes due to MatrixEvent being TSified --- src/client.ts | 8 ++++---- src/models/relations.ts | 5 ++--- src/store/memory.ts | 4 ++-- src/store/stub.ts | 4 +++- src/webrtc/call.ts | 2 +- src/webrtc/callEventHandler.ts | 4 ++-- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/client.ts b/src/client.ts index 95e7fbd7f..853d2c7ae 100644 --- a/src/client.ts +++ b/src/client.ts @@ -21,7 +21,7 @@ limitations under the License. import { EventEmitter } from "events"; import { SyncApi } from "./sync"; -import { EventStatus, MatrixEvent } from "./models/event"; +import { EventStatus, IDecryptOptions, MatrixEvent } from "./models/event"; import { StubStore } from "./store/stub"; import { createNewMatrixCall, MatrixCall } from "./webrtc/call"; import { Filter } from "./filter"; @@ -3042,7 +3042,7 @@ export class MatrixClient extends EventEmitter { if (event && event.getType() === "m.room.power_levels") { // take a copy of the content to ensure we don't corrupt // existing client state with a failed power level change - content = utils.deepCopy(event.getContent()); + content = utils.deepCopy(event.getContent()) as typeof content; } content.users[userId] = powerLevel; const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", { @@ -5707,13 +5707,13 @@ export class MatrixClient extends EventEmitter { * @param {boolean} options.isRetry True if this is a retry (enables more logging) * @param {boolean} options.emit Emits "event.decrypted" if set to true */ - public decryptEventIfNeeded(event: MatrixEvent, options?: { emit: boolean, isRetry: boolean }): Promise { + public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise { if (event.shouldAttemptDecryption()) { event.attemptDecryption(this.crypto, options); } if (event.isBeingDecrypted()) { - return event._decryptionPromise; + return event.getDecryptionPromise(); } else { return Promise.resolve(); } diff --git a/src/models/relations.ts b/src/models/relations.ts index 5d70cffee..adefc71fe 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -319,8 +319,7 @@ export class Relations extends EventEmitter { // the all-knowning server tells us that the event at some point had // this timestamp for its replacement, so any following replacement should definitely not be less - const replaceRelation = - this.targetEvent.getServerAggregatedRelation(RelationType.Replace); + const replaceRelation = this.targetEvent.getServerAggregatedRelation(RelationType.Replace); const minTs = replaceRelation && replaceRelation.origin_server_ts; const lastReplacement = this.getRelations().reduce((last, event) => { @@ -339,7 +338,7 @@ export class Relations extends EventEmitter { if (lastReplacement?.shouldAttemptDecryption()) { await lastReplacement.attemptDecryption(this.room._client.crypto); } else if (lastReplacement?.isBeingDecrypted()) { - await lastReplacement._decryptionPromise; + await lastReplacement.getDecryptionPromise(); } return lastReplacement; diff --git a/src/store/memory.ts b/src/store/memory.ts index eda2adf3f..f682e10c1 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -59,7 +59,7 @@ export class MemoryStore implements IStore { // filterId: Filter // } private filters: Record> = {}; - private accountData: Record = {}; // type : content + private accountData: Record = {}; // type : content private readonly localStorage: Storage; private oobMembers: Record = {}; // roomId: [member events] private clientOptions = {}; @@ -330,7 +330,7 @@ export class MemoryStore implements IStore { * @param {string} eventType The event type being queried * @return {?MatrixEvent} the user account_data event of given type, if any */ - public getAccountData(eventType: EventType | string): MatrixEvent | null { + public getAccountData(eventType: EventType | string): MatrixEvent | undefined { return this.accountData[eventType]; } diff --git a/src/store/stub.ts b/src/store/stub.ts index a1775b3f9..c8dd293da 100644 --- a/src/store/stub.ts +++ b/src/store/stub.ts @@ -202,7 +202,9 @@ export class StubStore implements IStore { * Get account data event by event type * @param {string} eventType The event type being queried */ - public getAccountData(eventType: EventType | string): MatrixEvent {} + public getAccountData(eventType: EventType | string): MatrixEvent | undefined { + return undefined; + } /** * setSyncData does nothing as there is no backing data store. diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 009bce309..18a9542a3 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -24,7 +24,7 @@ limitations under the License. import { logger } from '../logger'; import { EventEmitter } from 'events'; import * as utils from '../utils'; -import MatrixEvent from '../models/event'; +import { MatrixEvent } from '../models/event'; import { EventType } from '../@types/event'; import { RoomMember } from '../models/room-member'; import { randomString } from '../randomstring'; diff --git a/src/webrtc/callEventHandler.ts b/src/webrtc/callEventHandler.ts index 5394f1cbd..9d62375e9 100644 --- a/src/webrtc/callEventHandler.ts +++ b/src/webrtc/callEventHandler.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixEvent from '../models/event'; +import { MatrixEvent } from '../models/event'; import { logger } from '../logger'; import { createNewMatrixCall, MatrixCall, CallErrorCode, CallState, CallDirection } from './call'; import { EventType } from '../@types/event'; @@ -244,7 +244,7 @@ export class CallEventHandler { } else { call.onRemoteIceCandidatesReceived(event); } - } else if ([EventType.CallHangup, EventType.CallReject].includes(event.getType())) { + } else if ([EventType.CallHangup, EventType.CallReject].includes(event.getType() as EventType)) { // Note that we also observe our own hangups here so we can see // if we've already rejected a call that would otherwise be valid if (!call) { From 7c61b9cf7e57ed47af47ca43ead624927474ecc2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:24:53 +0100 Subject: [PATCH 063/124] Fix more type definitions --- src/client.ts | 4 ++-- src/models/event.ts | 2 +- src/models/user.ts | 3 ++- src/sync.js | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/client.ts b/src/client.ts index 853d2c7ae..f34044081 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2730,7 +2730,7 @@ export class MatrixClient extends EventEmitter { * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public setAccountData(eventType: string, content: any, callback?: Callback): Promise { + public setAccountData(eventType: EventType | string, content: any, callback?: Callback): Promise { const path = utils.encodeUri("/user/$userId/account_data/$type", { $userId: this.credentials.userId, $type: eventType, @@ -2749,7 +2749,7 @@ export class MatrixClient extends EventEmitter { * @param {string} eventType The event type * @return {?object} The contents of the given account data event */ - public getAccountData(eventType: string): any { + public getAccountData(eventType: string): MatrixEvent { return this.store.getAccountData(eventType); } diff --git a/src/models/event.ts b/src/models/event.ts index 17c6be3ba..9ad43fd97 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -77,7 +77,7 @@ interface IUnsigned { redacted_because?: IEvent; } -interface IEvent { +export interface IEvent { event_id: string; type: string; content: IContent; diff --git a/src/models/user.ts b/src/models/user.ts index da5c4d78d..5eff5062c 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -42,6 +42,7 @@ export class User extends EventEmitter { presence: null, profile: null, }; + // eslint-disable-next-line camelcase public unstable_statusMessage = ""; /** @@ -208,7 +209,7 @@ export class User extends EventEmitter { * @fires module:client~MatrixClient#event:"User.unstable_statusMessage" */ // eslint-disable-next-line camelcase - public _unstable_updateStatusMessage(event: MatrixEvent): void { + public unstable_updateStatusMessage(event: MatrixEvent): void { if (!event.getContent()) this.unstable_statusMessage = ""; else this.unstable_statusMessage = event.getContent()["status"]; this.updateModifiedTime(); diff --git a/src/sync.js b/src/sync.js index 9929629c2..9d114943f 100644 --- a/src/sync.js +++ b/src/sync.js @@ -1272,10 +1272,10 @@ SyncApi.prototype._processSyncResponse = async function( if (e.isState() && e.getType() === "im.vector.user_status") { let user = client.store.getUser(e.getStateKey()); if (user) { - user._unstable_updateStatusMessage(e); + user.unstable_updateStatusMessage(e); } else { user = createNewUser(client, e.getStateKey()); - user._unstable_updateStatusMessage(e); + user.unstable_updateStatusMessage(e); client.store.storeUser(user); } } From 608b0e7b936bb27f198d6b5bbba79b1eb25f8cf8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 14:49:27 +0100 Subject: [PATCH 064/124] Fix up some more type defs --- src/models/event.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index 9ad43fd97..5438fa203 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -87,7 +87,7 @@ export interface IEvent { txn_id?: string; state_key?: string; membership?: string; - unsigned?: IUnsigned; + unsigned: IUnsigned; redacts?: string; // v1 legacy fields @@ -324,11 +324,11 @@ export class MatrixEvent extends EventEmitter { * * @return {Object} The event content JSON, or an empty object. */ - public getOriginalContent(): IContent { + public getOriginalContent(): T { if (this._localRedactionEvent) { - return {}; + return {} as T; } - return this.clearEvent.content || this.event.content || {}; + return (this.clearEvent.content || this.event.content || {}) as T; } /** @@ -338,9 +338,9 @@ export class MatrixEvent extends EventEmitter { * * @return {Object} The event content JSON, or an empty object. */ - public getContent(): IContent { + public getContent(): T { if (this._localRedactionEvent) { - return {}; + return {} as T; } else if (this._replacingEvent) { return this._replacingEvent.getContent()["m.new_content"] || {}; } else { From b1b7522b805707d7ec9329646e81604833659f6f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 15:18:52 +0100 Subject: [PATCH 065/124] Fix tests by updating private field names and spies --- spec/integ/megolm-integ.spec.js | 2 +- spec/unit/crypto.spec.js | 8 +++--- spec/unit/room-state.spec.js | 43 ++++++++++++--------------------- 3 files changed, 20 insertions(+), 33 deletions(-) diff --git a/spec/integ/megolm-integ.spec.js b/spec/integ/megolm-integ.spec.js index 513043410..d129590e1 100644 --- a/spec/integ/megolm-integ.spec.js +++ b/spec/integ/megolm-integ.spec.js @@ -1012,7 +1012,7 @@ describe("megolm", function() { }, event: true, }); - event._senderCurve25519Key = testSenderKey; + event.senderCurve25519Key = testSenderKey; return testClient.client.crypto._onRoomKeyEvent(event); }).then(() => { const event = testUtils.mkEvent({ diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index d1e707fd3..bda03089a 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -234,7 +234,7 @@ describe("Crypto", function() { }, }); // make onRoomKeyEvent think this was an encrypted event - ksEvent._senderCurve25519Key = "akey"; + ksEvent.senderCurve25519Key = "akey"; return ksEvent; } @@ -274,9 +274,9 @@ describe("Crypto", function() { // 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); - event._clearEvent = {}; - event._senderCurve25519Key = null; - event._claimedEd25519Key = null; + event.clearEvent = {}; + event.senderCurve25519Key = null; + event.claimedEd25519Key = null; try { await bobClient.crypto.decryptEvent(event); } catch (e) { diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index 182f38d12..31bf2e034 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -1,6 +1,5 @@ import * as utils from "../test-utils"; import { RoomState } from "../../src/models/room-state"; -import { RoomMember } from "../../src/models/room-member"; describe("RoomState", function() { const roomId = "!foo:bar"; @@ -193,12 +192,7 @@ describe("RoomState", function() { expect(emitCount).toEqual(2); }); - it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", - function() { - // mock up the room members - state.members[userA] = utils.mock(RoomMember); - state.members[userB] = utils.mock(RoomMember); - + it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function() { const powerLevelEvent = utils.mkEvent({ type: "m.room.power_levels", room: roomId, user: userA, event: true, content: { @@ -208,18 +202,16 @@ describe("RoomState", function() { }, }); + // spy on the room members + jest.spyOn(state.members[userA], "setPowerLevelEvent"); + jest.spyOn(state.members[userB], "setPowerLevelEvent"); state.setStateEvents([powerLevelEvent]); - expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith( - powerLevelEvent, - ); - expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith( - powerLevelEvent, - ); + expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); + expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); }); - it("should call setPowerLevelEvent on a new RoomMember if power levels exist", - function() { + it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function() { const memberEvent = utils.mkMembership({ mship: "join", user: userC, room: roomId, event: true, }); @@ -243,13 +235,12 @@ describe("RoomState", function() { }); it("should call setMembershipEvent on the right RoomMember", function() { - // mock up the room members - state.members[userA] = utils.mock(RoomMember); - state.members[userB] = utils.mock(RoomMember); - const memberEvent = utils.mkMembership({ user: userB, mship: "leave", room: roomId, event: true, }); + // spy on the room members + jest.spyOn(state.members[userA], "setMembershipEvent"); + jest.spyOn(state.members[userB], "setMembershipEvent"); state.setStateEvents([memberEvent]); expect(state.members[userA].setMembershipEvent).not.toHaveBeenCalled(); @@ -374,17 +365,13 @@ describe("RoomState", function() { user_ids: [userA], }, }); - // mock up the room members - state.members[userA] = utils.mock(RoomMember); - state.members[userB] = utils.mock(RoomMember); + // spy on the room members + jest.spyOn(state.members[userA], "setTypingEvent"); + jest.spyOn(state.members[userB], "setTypingEvent"); state.setTypingEvent(typingEvent); - expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith( - typingEvent, - ); - expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith( - typingEvent, - ); + expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith(typingEvent); + expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith(typingEvent); }); }); From a2449ff6a7f2efbb4968085f38ea1b35d25050b9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 17 Jun 2021 15:23:40 +0100 Subject: [PATCH 066/124] Fix typos --- src/models/event.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index 5438fa203..246390629 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -492,8 +492,8 @@ export class MatrixEvent extends EventEmitter { * * @param {module:crypto} crypto crypto module * @param {object} options - * @param {bool} options.isRetry True if this is a retry (enables more logging) - * @param {bool} options.emit Emits "event.decrypted" if set to true + * @param {boolean} options.isRetry True if this is a retry (enables more logging) + * @param {boolean} options.emit Emits "event.decrypted" if set to true * * @returns {Promise} promise which resolves (to undefined) when the decryption * attempt is completed. @@ -1258,6 +1258,6 @@ const REDACT_KEEP_CONTENT_MAP = { * @param {module:models/event.MatrixEvent} event * The matrix event which has been decrypted * @param {module:crypto/algorithms/base.DecryptionError?} err - * The error that occured during decryption, or `undefined` if no - * error occured. + * The error that occurred during decryption, or `undefined` if no + * error occurred. */ From 09b729beb550d0e1df16d4821811fce51586dfb4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Jun 2021 17:07:56 +0000 Subject: [PATCH 067/124] Bump lodash from 4.17.20 to 4.17.21 Bumps [lodash](https://github.com/lodash/lodash) from 4.17.20 to 4.17.21. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.20...4.17.21) --- updated-dependencies: - dependency-name: lodash dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index b582d75fb..43a37edbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4705,12 +4705,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.20: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== - -lodash@^4.17.15, lodash@^4.17.4: +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== From b15487ec03e8ed7f8c15bb67815385c2a0c1e06b Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 17 Jun 2021 14:24:39 -0600 Subject: [PATCH 068/124] Misc lint --- spec/unit/models/event.spec.ts | 6 +++--- spec/unit/utils.spec.ts | 3 ++- src/models/event.js | 9 +++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts index cafb318e1..f1a8969dd 100644 --- a/spec/unit/models/event.spec.ts +++ b/spec/unit/models/event.spec.ts @@ -26,7 +26,7 @@ describe('MatrixEvent', () => { }, }); - const clone = a.getSnapshotCopy(); + const clone = a.toSnapshot(); expect(clone).toBeDefined(); expect(clone).not.toBe(a); expect(clone.event).not.toBe(a.event); @@ -54,7 +54,7 @@ describe('MatrixEvent', () => { expect(a.isEquivalentTo(a)).toBe(true); expect(b.isEquivalentTo(a)).toBe(false); expect(b.isEquivalentTo(b)).toBe(true); - expect(a.getSnapshotCopy().isEquivalentTo(a)).toBe(true); - expect(a.getSnapshotCopy().isEquivalentTo(b)).toBe(false); + expect(a.toSnapshot().isEquivalentTo(a)).toBe(true); + expect(a.toSnapshot().isEquivalentTo(b)).toBe(false); }); }); diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts index 5867a9fde..0f01318d2 100644 --- a/spec/unit/utils.spec.ts +++ b/spec/unit/utils.spec.ts @@ -2,7 +2,8 @@ import * as utils from "../../src/utils"; import { alphabetPad, averageBetweenStrings, - baseToString, deepSortedObjectEntries, + baseToString, + deepSortedObjectEntries, DEFAULT_ALPHABET, lexicographicCompare, nextString, diff --git a/src/models/event.js b/src/models/event.js index 838cd1a3d..cce346db0 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -1148,14 +1148,15 @@ utils.extend(MatrixEvent.prototype, { /** * Get a copy/snapshot of this event. The returned copy will be loosely linked * back to this instance, though will have "frozen" event information. Other - * properties may mutate depending on the state of this instance at the time - * of snapshotting. + * properties of this MatrixEvent instance will be copied verbatim, which can + * mean they are in reference to this instance despite being on the copy too. + * Consumers should be wary of using fields which may mutate over time. * * This is meant to be used to snapshot the event details themselves, not the * features (such as sender) surrounding the event. * @returns {MatrixEvent} A snapshot of this event. */ - getSnapshotCopy() { + toSnapshot() { const ev = new MatrixEvent(JSON.parse(JSON.stringify(this.event))); for (const [p, v] of Object.entries(this)) { if (p !== "event") { // exclude the thing we just cloned @@ -1168,7 +1169,7 @@ utils.extend(MatrixEvent.prototype, { /** * Determines if this event is equivalent to the given event. This only checks * the event object itself, not the other properties of the event. Intended for - * use with getSnapshotCopy() to identify events changing. + * use with toSnapshot() to identify events changing. * @param {MatrixEvent} otherEvent The other event to check against. * @returns {boolean} True if the events are the same, false otherwise. */ From 39892c98f95320862eb9ba556b3768f0abc269d4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 17 Jun 2021 17:36:49 -0600 Subject: [PATCH 069/124] Add keysharing on invites to File Tree Spaces --- spec/unit/models/MSC3089TreeSpace.spec.ts | 59 +++++++++++++++++++++-- src/crypto/algorithms/megolm.js | 2 +- src/models/MSC3089TreeSpace.ts | 22 +++++++-- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index a7f42e07b..e95d625f6 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -105,7 +105,7 @@ describe("MSC3089TreeSpace", () => { return Promise.resolve(); }); client.invite = fn; - await tree.invite(target, false); + await tree.invite(target, false, false); expect(fn).toHaveBeenCalledTimes(1); }); @@ -118,7 +118,7 @@ describe("MSC3089TreeSpace", () => { return Promise.resolve(); }); client.invite = fn; - await tree.invite(target, false); + await tree.invite(target, false, false); expect(fn).toHaveBeenCalledTimes(2); }); @@ -131,7 +131,7 @@ describe("MSC3089TreeSpace", () => { }); client.invite = fn; try { - await tree.invite(target, false); + await tree.invite(target, false, false); // noinspection ExceptionCaughtLocallyJS throw new Error("Failed to fail"); @@ -159,10 +159,61 @@ describe("MSC3089TreeSpace", () => { { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, ]; - await tree.invite(target, true); + await tree.invite(target, true, false); expect(fn).toHaveBeenCalledTimes(4); }); + it('should share keys with invitees', async () => { + const target = targetUser; + const sendKeysFn = jest.fn().mockImplementation((inviteRoomId: string, userIds: string[]) => { + expect(inviteRoomId).toEqual(roomId); + expect(userIds).toMatchObject([target]); + return Promise.resolve(); + }); + client.invite = () => Promise.resolve(); // we're not testing this here - see other tests + client.sendSharedHistoryKeys = sendKeysFn; + + // Mock the history check as best as possible + const historyVis = "shared"; + const historyFn = jest.fn().mockImplementation((eventType: string, stateKey?: string) => { + // We're not expecting a super rigid test: the function that calls this internally isn't + // really being tested here. + expect(eventType).toEqual(EventType.RoomHistoryVisibility); + expect(stateKey).toEqual(""); + return {getContent: () => ({history_visibility: historyVis})}; // eslint-disable-line camelcase + }); + room.currentState.getStateEvents = historyFn; + + // Note: inverse test is implicit from other tests, which disable the call stack of this + // test in order to pass. + await tree.invite(target, false, true); + expect(sendKeysFn).toHaveBeenCalledTimes(1); + expect(historyFn).toHaveBeenCalledTimes(1); + }); + + it('should not share keys with invitees if inappropriate history visibility', async () => { + const target = targetUser; + const sendKeysFn = jest.fn().mockImplementation((inviteRoomId: string, userIds: string[]) => { + expect(inviteRoomId).toEqual(roomId); + expect(userIds).toMatchObject([target]); + return Promise.resolve(); + }); + client.invite = () => Promise.resolve(); // we're not testing this here - see other tests + client.sendSharedHistoryKeys = sendKeysFn; + + const historyVis = "joined"; // NOTE: Changed. + const historyFn = jest.fn().mockImplementation((eventType: string, stateKey?: string) => { + expect(eventType).toEqual(EventType.RoomHistoryVisibility); + expect(stateKey).toEqual(""); + return {getContent: () => ({history_visibility: historyVis})}; // eslint-disable-line camelcase + }); + room.currentState.getStateEvents = historyFn; + + await tree.invite(target, false, true); + expect(sendKeysFn).toHaveBeenCalledTimes(0); + expect(historyFn).toHaveBeenCalledTimes(1); + }); + async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) { makePowerLevels(pls); const fn = jest.fn() diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 138566892..e6d6e0b62 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -37,7 +37,7 @@ import { import { WITHHELD_MESSAGES } from '../OlmDevice'; // determine whether the key can be shared with invitees -function isRoomSharedHistory(room) { +export function isRoomSharedHistory(room) { const visibilityEvent = room.currentState && room.currentState.getStateEvents("m.room.history_visibility", ""); // NOTE: if the room visibility is unset, it would normally default to diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index 5b4771b27..5a51e16ce 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -29,6 +29,7 @@ import { } from "../utils"; import { MSC3089Branch } from "./MSC3089Branch"; import promiseRetry from "p-retry"; +import { isRoomSharedHistory } from "../crypto/algorithms/megolm"; /** * The recommended defaults for a tree space's power levels. Note that this @@ -120,15 +121,28 @@ export class MSC3089TreeSpace { * @param {string} userId The user ID to invite. * @param {boolean} andSubspaces True (default) to invite the user to all * directories/subspaces too, recursively. + * @param {boolean} shareHistoryKeys True (default) to share encryption keys + * with the invited user. This will allow them to decrypt the events (files) + * in the tree. Keys will not be shared if the room is lacking appropriate + * history visibility (by default, history visibility is "shared" in trees, + * which is an appropriate visibility for these purposes). * @returns {Promise} Resolves when complete. */ - public invite(userId: string, andSubspaces = true): Promise { - // TODO: [@@TR] Share keys + public invite(userId: string, andSubspaces = true, shareHistoryKeys = true): Promise { const promises: Promise[] = [this.retryInvite(userId)]; if (andSubspaces) { - promises.push(...this.getDirectories().map(d => d.invite(userId, andSubspaces))); + promises.push(...this.getDirectories().map(d => d.invite(userId, andSubspaces, shareHistoryKeys))); } - return Promise.all(promises).then(); // .then() to coerce types + return Promise.all(promises).then(() => { + // Note: key sharing is default on because for file trees it is relatively important that the invite + // target can actually decrypt the files. The implied use case is that by inviting a user to the tree + // it means the sender would like the receiver to view/download the files contained within, much like + // sharing a folder in other circles. + if (shareHistoryKeys && isRoomSharedHistory(this.room)) { + // noinspection JSIgnoredPromiseFromCall - we aren't concerned as much if this fails. + this.client.sendSharedHistoryKeys(this.roomId, [userId]); + } + }); } private retryInvite(userId: string): Promise { From c1bff0b2eab52b0490aec3c819e5b255256f59ba Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 17 Jun 2021 17:38:57 -0600 Subject: [PATCH 070/124] delint --- spec/unit/models/MSC3089TreeSpace.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index e95d625f6..951ab4c0e 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -180,7 +180,7 @@ describe("MSC3089TreeSpace", () => { // really being tested here. expect(eventType).toEqual(EventType.RoomHistoryVisibility); expect(stateKey).toEqual(""); - return {getContent: () => ({history_visibility: historyVis})}; // eslint-disable-line camelcase + return { getContent: () => ({ history_visibility: historyVis }) }; // eslint-disable-line camelcase }); room.currentState.getStateEvents = historyFn; @@ -205,7 +205,7 @@ describe("MSC3089TreeSpace", () => { const historyFn = jest.fn().mockImplementation((eventType: string, stateKey?: string) => { expect(eventType).toEqual(EventType.RoomHistoryVisibility); expect(stateKey).toEqual(""); - return {getContent: () => ({history_visibility: historyVis})}; // eslint-disable-line camelcase + return { getContent: () => ({ history_visibility: historyVis }) }; // eslint-disable-line camelcase }); room.currentState.getStateEvents = historyFn; From 7aa5a79c86d23c01a3a0212e3079cf83d30dcc0d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 18 Jun 2021 15:29:45 +0100 Subject: [PATCH 071/124] Convert Room and RoomState to Typescript --- src/@types/partials.ts | 2 + src/client.ts | 49 +- src/content-repo.ts | 2 +- src/models/event.ts | 4 + src/models/room-state.js | 832 ------------- src/models/room-state.ts | 825 +++++++++++++ src/models/room-summary.ts | 14 +- src/models/room.js | 2254 ----------------------------------- src/models/room.ts | 2272 ++++++++++++++++++++++++++++++++++++ 9 files changed, 3149 insertions(+), 3105 deletions(-) delete mode 100644 src/models/room-state.js create mode 100644 src/models/room-state.ts delete mode 100644 src/models/room.js create mode 100644 src/models/room.ts diff --git a/src/@types/partials.ts b/src/@types/partials.ts index 771c60b47..ecdc6525a 100644 --- a/src/@types/partials.ts +++ b/src/@types/partials.ts @@ -37,3 +37,5 @@ export enum Preset { TrustedPrivateChat = "trusted_private_chat", PublicChat = "public_chat", } + +export type ResizeMethod = "crop" | "scale"; diff --git a/src/client.ts b/src/client.ts index f34044081..1d54523ca 100644 --- a/src/client.ts +++ b/src/client.ts @@ -32,7 +32,6 @@ import { Group } from "./models/group"; import { EventTimeline } from "./models/event-timeline"; import { PushAction, PushProcessor } from "./pushprocessor"; import { AutoDiscovery } from "./autodiscovery"; -import { MatrixError } from "./http-api"; import * as olmlib from "./crypto/olmlib"; import { decodeBase64, encodeBase64 } from "./crypto/olmlib"; import { ReEmitter } from './ReEmitter'; @@ -40,6 +39,7 @@ import { RoomList } from './crypto/RoomList'; import { logger } from './logger'; import { SERVICE_TYPES } from './service-types'; import { + MatrixError, MatrixHttpApi, PREFIX_IDENTITY_V2, PREFIX_MEDIA_R0, @@ -64,7 +64,7 @@ import { import { IIdentityServerProvider } from "./@types/IIdentityServerProvider"; import type Request from "request"; import { MatrixScheduler } from "./scheduler"; -import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from "./matrix"; +import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo, NotificationCountType } from "./matrix"; import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store"; import { LocalStorageCryptoStore } from "./crypto/store/localStorage-crypto-store"; import { IndexedDBCryptoStore } from "./crypto/store/indexeddb-crypto-store"; @@ -94,7 +94,8 @@ import { IJoinRoomOpts, IPaginateOpts, IPresenceOpts, - IRedactOpts, IRoomDirectoryOptions, + IRedactOpts, + IRoomDirectoryOptions, ISearchOpts, ISendEventResponse, IUploadOpts, @@ -348,6 +349,26 @@ export interface IStoredClientOpts extends IStartClientOpts { canResetEntireTimeline: ResetTimelineCallback; } +export enum RoomVersionStability { + Stable = "stable", + Unstable = "unstable", +} + +export interface IRoomVersionsCapability { + default: string; + available: Record; +} + +export interface IChangePasswordCapability { + enabled: boolean; +} + +interface ICapabilities { + [key: string]: any; + "m.change_password"?: IChangePasswordCapability; + "m.room_versions"?: IRoomVersionsCapability; +} + /** * Represents a Matrix Client. Only directly construct this if you want to use * custom modules. Normally, {@link createClient} should be used @@ -408,7 +429,7 @@ export class MatrixClient extends EventEmitter { protected serverVersionsPromise: Promise; protected cachedCapabilities: { - capabilities: Record; + capabilities: ICapabilities; expiration: number; }; protected clientWellKnown: any; @@ -529,7 +550,7 @@ export class MatrixClient extends EventEmitter { const room = this.getRoom(event.getRoomId()); if (!room) return; - const currentCount = room.getUnreadNotificationCount("highlight"); + const currentCount = room.getUnreadNotificationCount(NotificationCountType.Highlight); // Ensure the unread counts are kept up to date if the event is encrypted // We also want to make sure that the notification count goes up if we already @@ -545,12 +566,12 @@ export class MatrixClient extends EventEmitter { let newCount = currentCount; if (newHighlight && !oldHighlight) newCount++; if (!newHighlight && oldHighlight) newCount--; - room.setUnreadNotificationCount("highlight", newCount); + room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); // Fix 'Mentions Only' rooms from not having the right badge count - const totalCount = room.getUnreadNotificationCount('total'); + const totalCount = room.getUnreadNotificationCount(NotificationCountType.Total); if (totalCount < newCount) { - room.setUnreadNotificationCount('total', newCount); + room.setUnreadNotificationCount(NotificationCountType.Total, newCount); } } } @@ -1058,7 +1079,7 @@ export class MatrixClient extends EventEmitter { * @return {Promise} Resolves to the capabilities of the homeserver * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public getCapabilities(fresh = false): Promise> { + public getCapabilities(fresh = false): Promise { const now = new Date().getTime(); if (this.cachedCapabilities && !fresh) { @@ -1076,7 +1097,7 @@ export class MatrixClient extends EventEmitter { return null; // otherwise consume the error }).then((r) => { if (!r) r = {}; - const capabilities = r["capabilities"] || {}; + const capabilities: ICapabilities = r["capabilities"] || {}; // If the capabilities missed the cache, cache it for a shorter amount // of time to try and refresh them later. @@ -1085,7 +1106,7 @@ export class MatrixClient extends EventEmitter { : 60000 + (Math.random() * 5000); this.cachedCapabilities = { - capabilities: capabilities, + capabilities, expiration: now + cacheMs, }; @@ -3544,7 +3565,7 @@ export class MatrixClient extends EventEmitter { const room = this.getRoom(event.getRoomId()); if (room) { - room._addLocalEchoReceipt(this.credentials.userId, event, receiptType); + room.addLocalEchoReceipt(this.credentials.userId, event, receiptType); } return promise; } @@ -3614,7 +3635,7 @@ export class MatrixClient extends EventEmitter { throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`); } if (room) { - room._addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read"); + room.addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read"); } } @@ -4230,7 +4251,7 @@ export class MatrixClient extends EventEmitter { // reduce the required number of events appropriately limit = limit - numAdded; - const prom = new Promise((resolve, reject) => { + const prom = new Promise((resolve, reject) => { // wait for a time before doing this request // (which may be 0 in order not to special case the code paths) sleep(timeToWaitMs).then(() => { diff --git a/src/content-repo.ts b/src/content-repo.ts index baa91879b..287259651 100644 --- a/src/content-repo.ts +++ b/src/content-repo.ts @@ -39,7 +39,7 @@ export function getHttpUriForMxc( width: number, height: number, resizeMethod: string, - allowDirectLinks: boolean, + allowDirectLinks = false, ): string { if (typeof mxc !== "string" || !mxc) { return ''; diff --git a/src/models/event.ts b/src/models/event.ts index 246390629..04c1afd4b 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -70,11 +70,15 @@ interface IContent { "m.relates_to"?: IEventRelation; } +type StrippedState = Required>; + interface IUnsigned { age?: number; prev_sender?: string; prev_content?: IContent; redacted_because?: IEvent; + transaction_id?: string; + invite_room_state?: StrippedState[]; } export interface IEvent { diff --git a/src/models/room-state.js b/src/models/room-state.js deleted file mode 100644 index f9e76cfc3..000000000 --- a/src/models/room-state.js +++ /dev/null @@ -1,832 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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 models/room-state - */ - -import { EventEmitter } from "events"; -import { RoomMember } from "./room-member"; -import { logger } from '../logger'; -import * as utils from "../utils"; -import { EventType } from "../@types/event"; - -// possible statuses for out-of-band member loading -const OOB_STATUS_NOTSTARTED = 1; -const OOB_STATUS_INPROGRESS = 2; -const OOB_STATUS_FINISHED = 3; - -/** - * Construct room state. - * - * Room State represents the state of the room at a given point. - * It can be mutated by adding state events to it. - * There are two types of room member associated with a state event: - * normal member objects (accessed via getMember/getMembers) which mutate - * with the state to represent the current state of that room/user, eg. - * the object returned by getMember('@bob:example.com') will mutate to - * get a different display name if Bob later changes his display name - * in the room. - * There are also 'sentinel' members (accessed via getSentinelMember). - * These also represent the state of room members at the point in time - * represented by the RoomState object, but unlike objects from getMember, - * sentinel objects will always represent the room state as at the time - * getSentinelMember was called, so if Bob subsequently changes his display - * name, a room member object previously acquired with getSentinelMember - * will still have his old display name. Calling getSentinelMember again - * after the display name change will return a new RoomMember object - * with Bob's new display name. - * - * @constructor - * @param {?string} roomId Optional. The ID of the room which has this state. - * If none is specified it just tracks paginationTokens, useful for notifTimelineSet - * @param {?object} oobMemberFlags Optional. The state of loading out of bound members. - * As the timeline might get reset while they are loading, this state needs to be inherited - * and shared when the room state is cloned for the new timeline. - * This should only be passed from clone. - * @prop {Object.} members The room member dictionary, keyed - * on the user's ID. - * @prop {Object.>} events The state - * events dictionary, keyed on the event type and then the state_key value. - * @prop {string} paginationToken The pagination token for this state. - */ -export function RoomState(roomId, oobMemberFlags = undefined) { - this.roomId = roomId; - this.members = { - // userId: RoomMember - }; - this.events = new Map(); // Map> - this.paginationToken = null; - - this._sentinels = { - // userId: RoomMember - }; - this._updateModifiedTime(); - - // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) - this._displayNameToUserIds = {}; - this._userIdsToDisplayNames = {}; - this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite - this._joinedMemberCount = null; // cache of the number of joined members - // joined members count from summary api - // once set, we know the server supports the summary api - // and we should only trust that - // we could also only trust that before OOB members - // are loaded but doesn't seem worth the hassle atm - this._summaryJoinedMemberCount = null; - // same for invited member count - this._invitedMemberCount = null; - this._summaryInvitedMemberCount = null; - - if (!oobMemberFlags) { - oobMemberFlags = { - status: OOB_STATUS_NOTSTARTED, - }; - } - this._oobMemberFlags = oobMemberFlags; -} -utils.inherits(RoomState, EventEmitter); - -/** - * Returns the number of joined members in this room - * This method caches the result. - * @return {integer} The number of members in this room whose membership is 'join' - */ -RoomState.prototype.getJoinedMemberCount = function() { - if (this._summaryJoinedMemberCount !== null) { - return this._summaryJoinedMemberCount; - } - if (this._joinedMemberCount === null) { - this._joinedMemberCount = this.getMembers().reduce((count, m) => { - return m.membership === 'join' ? count + 1 : count; - }, 0); - } - return this._joinedMemberCount; -}; - -/** - * Set the joined member count explicitly (like from summary part of the sync response) - * @param {number} count the amount of joined members - */ -RoomState.prototype.setJoinedMemberCount = function(count) { - this._summaryJoinedMemberCount = count; -}; -/** - * Returns the number of invited members in this room - * @return {integer} The number of members in this room whose membership is 'invite' - */ -RoomState.prototype.getInvitedMemberCount = function() { - if (this._summaryInvitedMemberCount !== null) { - return this._summaryInvitedMemberCount; - } - if (this._invitedMemberCount === null) { - this._invitedMemberCount = this.getMembers().reduce((count, m) => { - return m.membership === 'invite' ? count + 1 : count; - }, 0); - } - return this._invitedMemberCount; -}; - -/** - * Set the amount of invited members in this room - * @param {number} count the amount of invited members - */ -RoomState.prototype.setInvitedMemberCount = function(count) { - this._summaryInvitedMemberCount = count; -}; - -/** - * Get all RoomMembers in this room. - * @return {Array} A list of RoomMembers. - */ -RoomState.prototype.getMembers = function() { - return Object.values(this.members); -}; - -/** - * Get all RoomMembers in this room, excluding the user IDs provided. - * @param {Array} excludedIds The user IDs to exclude. - * @return {Array} A list of RoomMembers. - */ -RoomState.prototype.getMembersExcept = function(excludedIds) { - return Object.values(this.members) - .filter((m) => !excludedIds.includes(m.userId)); -}; - -/** - * Get a room member by their user ID. - * @param {string} userId The room member's user ID. - * @return {RoomMember} The member or null if they do not exist. - */ -RoomState.prototype.getMember = function(userId) { - return this.members[userId] || null; -}; - -/** - * Get a room member whose properties will not change with this room state. You - * typically want this if you want to attach a RoomMember to a MatrixEvent which - * may no longer be represented correctly by Room.currentState or Room.oldState. - * The term 'sentinel' refers to the fact that this RoomMember is an unchanging - * guardian for state at this particular point in time. - * @param {string} userId The room member's user ID. - * @return {RoomMember} The member or null if they do not exist. - */ -RoomState.prototype.getSentinelMember = function(userId) { - if (!userId) return null; - let sentinel = this._sentinels[userId]; - - if (sentinel === undefined) { - sentinel = new RoomMember(this.roomId, userId); - const member = this.members[userId]; - if (member) { - sentinel.setMembershipEvent(member.events.member, this); - } - this._sentinels[userId] = sentinel; - } - return sentinel; -}; - -/** - * Get state events from the state of the room. - * @param {string} eventType The event type of the state event. - * @param {string} stateKey Optional. The state_key of the state event. If - * this is undefined then all matching state events will be - * returned. - * @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was - * undefined, else a single event (or null if no match found). - */ -RoomState.prototype.getStateEvents = function(eventType, stateKey) { - if (!this.events.has(eventType)) { - // no match - return stateKey === undefined ? [] : null; - } - if (stateKey === undefined) { // return all values - return Array.from(this.events.get(eventType).values()); - } - const event = this.events.get(eventType).get(stateKey); - return event ? event : null; -}; - -/** - * Creates a copy of this room state so that mutations to either won't affect the other. - * @return {RoomState} the copy of the room state - */ -RoomState.prototype.clone = function() { - const copy = new RoomState(this.roomId, this._oobMemberFlags); - - // Ugly hack: because setStateEvents will mark - // members as susperseding future out of bound members - // if loading is in progress (through _oobMemberFlags) - // since these are not new members, we're merely copying them - // set the status to not started - // after copying, we set back the status - const status = this._oobMemberFlags.status; - this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; - - Array.from(this.events.values()).forEach((eventsByStateKey) => { - copy.setStateEvents(Array.from(eventsByStateKey.values())); - }); - - // Ugly hack: see above - this._oobMemberFlags.status = status; - - if (this._summaryInvitedMemberCount !== null) { - copy.setInvitedMemberCount(this.getInvitedMemberCount()); - } - if (this._summaryJoinedMemberCount !== null) { - copy.setJoinedMemberCount(this.getJoinedMemberCount()); - } - - // copy out of band flags if needed - if (this._oobMemberFlags.status == OOB_STATUS_FINISHED) { - // copy markOutOfBand flags - this.getMembers().forEach((member) => { - if (member.isOutOfBand()) { - const copyMember = copy.getMember(member.userId); - copyMember.markOutOfBand(); - } - }); - } - - return copy; -}; - -/** - * Add previously unknown state events. - * When lazy loading members while back-paginating, - * the relevant room state for the timeline chunk at the end - * of the chunk can be set with this method. - * @param {MatrixEvent[]} events state events to prepend - */ -RoomState.prototype.setUnknownStateEvents = function(events) { - const unknownStateEvents = events.filter((event) => { - return !this.events.has(event.getType()) || - !this.events.get(event.getType()).has(event.getStateKey()); - }); - - this.setStateEvents(unknownStateEvents); -}; - -/** - * Add an array of one or more state MatrixEvents, overwriting - * any existing state with the same {type, stateKey} tuple. Will fire - * "RoomState.events" for every event added. May fire "RoomState.members" - * if there are m.room.member events. - * @param {MatrixEvent[]} stateEvents a list of state events for this room. - * @fires module:client~MatrixClient#event:"RoomState.members" - * @fires module:client~MatrixClient#event:"RoomState.newMember" - * @fires module:client~MatrixClient#event:"RoomState.events" - */ -RoomState.prototype.setStateEvents = function(stateEvents) { - const self = this; - this._updateModifiedTime(); - - // update the core event dict - stateEvents.forEach(function(event) { - if (event.getRoomId() !== self.roomId) { - return; - } - if (!event.isState()) { - return; - } - - const lastStateEvent = self._getStateEventMatching(event); - self._setStateEvent(event); - if (event.getType() === "m.room.member") { - _updateDisplayNameCache( - self, event.getStateKey(), event.getContent().displayname, - ); - _updateThirdPartyTokenCache(self, event); - } - self.emit("RoomState.events", event, self, lastStateEvent); - }); - - // update higher level data structures. This needs to be done AFTER the - // core event dict as these structures may depend on other state events in - // the given array (e.g. disambiguating display names in one go to do both - // clashing names rather than progressively which only catches 1 of them). - stateEvents.forEach(function(event) { - if (event.getRoomId() !== self.roomId) { - return; - } - if (!event.isState()) { - return; - } - - if (event.getType() === "m.room.member") { - const userId = event.getStateKey(); - - // leave events apparently elide the displayname or avatar_url, - // so let's fake one up so that we don't leak user ids - // into the timeline - if (event.getContent().membership === "leave" || - event.getContent().membership === "ban") { - event.getContent().avatar_url = - event.getContent().avatar_url || - event.getPrevContent().avatar_url; - event.getContent().displayname = - event.getContent().displayname || - event.getPrevContent().displayname; - } - - const member = self._getOrCreateMember(userId, event); - member.setMembershipEvent(event, self); - - 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 - // power levels has been changed - // large room suffer from large re-rendering especially when not needed - const oldLastModified = member.getLastModifiedTime(); - member.setPowerLevelEvent(event); - if (oldLastModified !== member.getLastModifiedTime()) { - self.emit("RoomState.members", event, self, member); - } - }); - - // assume all our sentinels are now out-of-date - self._sentinels = {}; - } - }); -}; - -/** - * Looks up a member by the given userId, and if it doesn't exist, - * create it and emit the `RoomState.newMember` event. - * This method makes sure the member is added to the members dictionary - * before emitting, as this is done from setStateEvents and _setOutOfBandMember. - * @param {string} userId the id of the user to look up - * @param {MatrixEvent} event the membership event for the (new) member. Used to emit. - * @fires module:client~MatrixClient#event:"RoomState.newMember" - * @returns {RoomMember} the member, existing or newly created. - */ -RoomState.prototype._getOrCreateMember = function(userId, event) { - let member = this.members[userId]; - if (!member) { - member = new RoomMember(this.roomId, userId); - // add member to members before emitting any events, - // as event handlers often lookup the member - this.members[userId] = member; - this.emit("RoomState.newMember", event, this, member); - } - return member; -}; - -RoomState.prototype._setStateEvent = function(event) { - if (!this.events.has(event.getType())) { - this.events.set(event.getType(), new Map()); - } - this.events.get(event.getType()).set(event.getStateKey(), event); -}; - -RoomState.prototype._getStateEventMatching = function(event) { - if (!this.events.has(event.getType())) return null; - return this.events.get(event.getType()).get(event.getStateKey()); -}; - -RoomState.prototype._updateMember = function(member) { - // this member may have a power level already, so set it. - const pwrLvlEvent = this.getStateEvents("m.room.power_levels", ""); - if (pwrLvlEvent) { - member.setPowerLevelEvent(pwrLvlEvent); - } - - // blow away the sentinel which is now outdated - delete this._sentinels[member.userId]; - - this.members[member.userId] = member; - this._joinedMemberCount = null; - this._invitedMemberCount = null; -}; - -/** - * Get the out-of-band members loading state, whether loading is needed or not. - * Note that loading might be in progress and hence isn't needed. - * @return {bool} whether or not the members of this room need to be loaded - */ -RoomState.prototype.needsOutOfBandMembers = function() { - return this._oobMemberFlags.status === OOB_STATUS_NOTSTARTED; -}; - -/** - * Mark this room state as waiting for out-of-band members, - * ensuring it doesn't ask for them to be requested again - * through needsOutOfBandMembers - */ -RoomState.prototype.markOutOfBandMembersStarted = function() { - if (this._oobMemberFlags.status !== OOB_STATUS_NOTSTARTED) { - return; - } - this._oobMemberFlags.status = OOB_STATUS_INPROGRESS; -}; - -/** - * Mark this room state as having failed to fetch out-of-band members - */ -RoomState.prototype.markOutOfBandMembersFailed = function() { - if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) { - return; - } - this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; -}; - -/** - * Clears the loaded out-of-band members - */ -RoomState.prototype.clearOutOfBandMembers = function() { - let count = 0; - Object.keys(this.members).forEach((userId) => { - const member = this.members[userId]; - if (member.isOutOfBand()) { - ++count; - delete this.members[userId]; - } - }); - logger.log(`LL: RoomState removed ${count} members...`); - this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; -}; - -/** - * Sets the loaded out-of-band members. - * @param {MatrixEvent[]} stateEvents array of membership state events - */ -RoomState.prototype.setOutOfBandMembers = function(stateEvents) { - logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`); - if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) { - return; - } - logger.log(`LL: RoomState put in OOB_STATUS_FINISHED state ...`); - this._oobMemberFlags.status = OOB_STATUS_FINISHED; - stateEvents.forEach((e) => this._setOutOfBandMember(e)); -}; - -/** - * Sets a single out of band member, used by both setOutOfBandMembers and clone - * @param {MatrixEvent} stateEvent membership state event - */ -RoomState.prototype._setOutOfBandMember = function(stateEvent) { - if (stateEvent.getType() !== 'm.room.member') { - return; - } - const userId = stateEvent.getStateKey(); - const existingMember = this.getMember(userId); - // never replace members received as part of the sync - if (existingMember && !existingMember.isOutOfBand()) { - return; - } - - const member = this._getOrCreateMember(userId, stateEvent); - member.setMembershipEvent(stateEvent, this); - // needed to know which members need to be stored seperately - // as they are not part of the sync accumulator - // this is cleared by setMembershipEvent so when it's updated through /sync - member.markOutOfBand(); - - _updateDisplayNameCache(this, member.userId, member.name); - - this._setStateEvent(stateEvent); - this._updateMember(member); - this.emit("RoomState.members", stateEvent, this, member); -}; - -/** - * Set the current typing event for this room. - * @param {MatrixEvent} event The typing event - */ -RoomState.prototype.setTypingEvent = function(event) { - Object.values(this.members).forEach(function(member) { - member.setTypingEvent(event); - }); -}; - -/** - * Get the m.room.member event which has the given third party invite token. - * - * @param {string} token The token - * @return {?MatrixEvent} The m.room.member event or null - */ -RoomState.prototype.getInviteForThreePidToken = function(token) { - return this._tokenToInvite[token] || null; -}; - -/** - * Update the last modified time to the current time. - */ -RoomState.prototype._updateModifiedTime = function() { - this._modified = Date.now(); -}; - -/** - * Get the timestamp when this room state was last updated. This timestamp is - * updated when this object has received new state events. - * @return {number} The timestamp - */ -RoomState.prototype.getLastModifiedTime = function() { - return this._modified; -}; - -/** - * Get user IDs with the specified or similar display names. - * @param {string} displayName The display name to get user IDs from. - * @return {string[]} An array of user IDs or an empty array. - */ -RoomState.prototype.getUserIdsWithDisplayName = function(displayName) { - return this._displayNameToUserIds[utils.removeHiddenChars(displayName)] || []; -}; - -/** - * Returns true if userId is in room, event is not redacted and either sender of - * mxEvent or has power level sufficient to redact events other than their own. - * @param {MatrixEvent} mxEvent The event to test permission for - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given used ID can redact given event - */ -RoomState.prototype.maySendRedactionForEvent = function(mxEvent, userId) { - const member = this.getMember(userId); - if (!member || member.membership === 'leave') return false; - - if (mxEvent.status || mxEvent.isRedacted()) return false; - - // The user may have been the sender, but they can't redact their own message - // if redactions are blocked. - const canRedact = this.maySendEvent("m.room.redaction", userId); - if (mxEvent.getSender() === userId) return canRedact; - - return this._hasSufficientPowerLevelFor('redact', member.powerLevel); -}; - -/** - * Returns true if the given power level is sufficient for action - * @param {string} action The type of power level to check - * @param {number} powerLevel The power level of the member - * @return {boolean} true if the given power level is sufficient - */ -RoomState.prototype._hasSufficientPowerLevelFor = function(action, powerLevel) { - const powerLevelsEvent = this.getStateEvents('m.room.power_levels', ''); - - let powerLevels = {}; - if (powerLevelsEvent) { - powerLevels = powerLevelsEvent.getContent(); - } - - let requiredLevel = 50; - if (utils.isNumber(powerLevels[action])) { - requiredLevel = powerLevels[action]; - } - - return powerLevel >= requiredLevel; -}; - -/** - * Short-form for maySendEvent('m.room.message', userId) - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID should be permitted to send - * message events into the given room. - */ -RoomState.prototype.maySendMessage = function(userId) { - return this._maySendEventOfType('m.room.message', userId, false); -}; - -/** - * Returns true if the given user ID has permission to send a normal - * event of type `eventType` into this room. - * @param {string} eventType The type of event to test - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID should be permitted to send - * the given type of event into this room, - * according to the room's state. - */ -RoomState.prototype.maySendEvent = function(eventType, userId) { - return this._maySendEventOfType(eventType, userId, false); -}; - -/** - * Returns true if the given MatrixClient has permission to send a state - * event of type `stateEventType` into this room. - * @param {string} stateEventType The type of state events to test - * @param {MatrixClient} cli The client to test permission for - * @return {boolean} true if the given client should be permitted to send - * the given type of state event into this room, - * according to the room's state. - */ -RoomState.prototype.mayClientSendStateEvent = function(stateEventType, cli) { - if (cli.isGuest()) { - return false; - } - return this.maySendStateEvent(stateEventType, cli.credentials.userId); -}; - -/** - * Returns true if the given user ID has permission to send a state - * event of type `stateEventType` into this room. - * @param {string} stateEventType The type of state events to test - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID should be permitted to send - * the given type of state event into this room, - * according to the room's state. - */ -RoomState.prototype.maySendStateEvent = function(stateEventType, userId) { - return this._maySendEventOfType(stateEventType, userId, true); -}; - -/** - * Returns true if the given user ID has permission to send a normal or state - * event of type `eventType` into this room. - * @param {string} eventType The type of event to test - * @param {string} userId The user ID of the user to test permission for - * @param {boolean} state If true, tests if the user may send a state - event of this type. Otherwise tests whether - they may send a regular event. - * @return {boolean} true if the given user ID should be permitted to send - * the given type of event into this room, - * according to the room's state. - */ -RoomState.prototype._maySendEventOfType = function(eventType, userId, state) { - const power_levels_event = this.getStateEvents('m.room.power_levels', ''); - - let power_levels; - let events_levels = {}; - - let state_default = 0; - let events_default = 0; - let powerLevel = 0; - if (power_levels_event) { - power_levels = power_levels_event.getContent(); - events_levels = power_levels.events || {}; - - if (Number.isFinite(power_levels.state_default)) { - state_default = power_levels.state_default; - } else { - state_default = 50; - } - - const userPowerLevel = power_levels.users && power_levels.users[userId]; - if (Number.isFinite(userPowerLevel)) { - powerLevel = userPowerLevel; - } else if (Number.isFinite(power_levels.users_default)) { - powerLevel = power_levels.users_default; - } - - if (Number.isFinite(power_levels.events_default)) { - events_default = power_levels.events_default; - } - } - - let required_level = state ? state_default : events_default; - if (Number.isFinite(events_levels[eventType])) { - required_level = events_levels[eventType]; - } - return powerLevel >= required_level; -}; - -/** - * Returns true if the given user ID has permission to trigger notification - * of type `notifLevelKey` - * @param {string} notifLevelKey The level of notification to test (eg. 'room') - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID has permission to trigger a - * notification of this type. - */ -RoomState.prototype.mayTriggerNotifOfType = function(notifLevelKey, userId) { - const member = this.getMember(userId); - if (!member) { - return false; - } - - const powerLevelsEvent = this.getStateEvents('m.room.power_levels', ''); - - let notifLevel = 50; - if ( - powerLevelsEvent && - powerLevelsEvent.getContent() && - powerLevelsEvent.getContent().notifications && - utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey]) - ) { - notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey]; - } - - return member.powerLevel >= notifLevel; -}; - -/** - * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. - * @returns {string} the join_rule applied to this room - */ -RoomState.prototype.getJoinRule = function() { - const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, ""); - const joinRuleContent = joinRuleEvent ? joinRuleEvent.getContent() : {}; - return joinRuleContent["join_rule"] || "invite"; -}; - -function _updateThirdPartyTokenCache(roomState, memberEvent) { - if (!memberEvent.getContent().third_party_invite) { - return; - } - const token = (memberEvent.getContent().third_party_invite.signed || {}).token; - if (!token) { - return; - } - const threePidInvite = roomState.getStateEvents( - "m.room.third_party_invite", token, - ); - if (!threePidInvite) { - return; - } - roomState._tokenToInvite[token] = memberEvent; -} - -function _updateDisplayNameCache(roomState, userId, displayName) { - const oldName = roomState._userIdsToDisplayNames[userId]; - delete roomState._userIdsToDisplayNames[userId]; - if (oldName) { - // Remove the old name from the cache. - // We clobber the user_id > name lookup but the name -> [user_id] lookup - // means we need to remove that user ID from that array rather than nuking - // the lot. - const strippedOldName = utils.removeHiddenChars(oldName); - - const existingUserIds = roomState._displayNameToUserIds[strippedOldName]; - if (existingUserIds) { - // remove this user ID from this array - const filteredUserIDs = existingUserIds.filter((id) => id !== userId); - roomState._displayNameToUserIds[strippedOldName] = filteredUserIDs; - } - } - - roomState._userIdsToDisplayNames[userId] = displayName; - - const strippedDisplayname = displayName && utils.removeHiddenChars(displayName); - // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js - if (strippedDisplayname) { - if (!roomState._displayNameToUserIds[strippedDisplayname]) { - roomState._displayNameToUserIds[strippedDisplayname] = []; - } - roomState._displayNameToUserIds[strippedDisplayname].push(userId); - } -} - -/** - * Fires whenever the event dictionary in room state is updated. - * @event module:client~MatrixClient#"RoomState.events" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomState} state The room state whose RoomState.events dictionary - * was updated. - * @param {MatrixEvent} prevEvent The event being replaced by the new state, if - * known. Note that this can differ from `getPrevContent()` on the new state event - * as this is the store's view of the last state, not the previous state provided - * by the server. - * @example - * matrixClient.on("RoomState.events", function(event, state, prevEvent){ - * var newStateEvent = event; - * }); - */ - -/** - * Fires whenever a member in the members dictionary is updated in any way. - * @event module:client~MatrixClient#"RoomState.members" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomState} state The room state whose RoomState.members dictionary - * was updated. - * @param {RoomMember} member The room member that was updated. - * @example - * matrixClient.on("RoomState.members", function(event, state, member){ - * var newMembershipState = member.membership; - * }); - */ - - /** - * Fires whenever a member is added to the members dictionary. The RoomMember - * will not be fully populated yet (e.g. no membership state) but will already - * be available in the members dictionary. - * @event module:client~MatrixClient#"RoomState.newMember" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomState} state The room state whose RoomState.members dictionary - * was updated with a new entry. - * @param {RoomMember} member The room member that was added. - * @example - * matrixClient.on("RoomState.newMember", function(event, state, member){ - * // add event listeners on 'member' - * }); - */ diff --git a/src/models/room-state.ts b/src/models/room-state.ts new file mode 100644 index 000000000..6b914a1d4 --- /dev/null +++ b/src/models/room-state.ts @@ -0,0 +1,825 @@ +/* +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. +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 models/room-state + */ + +import { EventEmitter } from "events"; + +import { RoomMember } from "./room-member"; +import { logger } from '../logger'; +import * as utils from "../utils"; +import { EventType } from "../@types/event"; +import { MatrixEvent } from "./event"; +import { MatrixClient } from "../client"; + +// possible statuses for out-of-band member loading +enum OobStatus { + NotStarted, + InProgress, + Finished, +} + +export class RoomState extends EventEmitter { + private sentinels: Record = {}; // userId: RoomMember + // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) + private displayNameToUserIds: Record = {}; + private userIdsToDisplayNames: Record = {}; + private tokenToInvite: Record = {}; // 3pid invite state_key to m.room.member invite + private joinedMemberCount: number = null; // cache of the number of joined members + // joined members count from summary api + // once set, we know the server supports the summary api + // and we should only trust that + // we could also only trust that before OOB members + // are loaded but doesn't seem worth the hassle atm + private summaryJoinedMemberCount: number = null; + // same for invited member count + private invitedMemberCount: number = null; + private summaryInvitedMemberCount: number = null; + private modified: number; + + // XXX: Should be read-only + public members: Record = {}; // userId: RoomMember + public events = new Map>(); // Map> + public paginationToken: string = null; + + /** + * Construct room state. + * + * Room State represents the state of the room at a given point. + * It can be mutated by adding state events to it. + * There are two types of room member associated with a state event: + * normal member objects (accessed via getMember/getMembers) which mutate + * with the state to represent the current state of that room/user, eg. + * the object returned by getMember('@bob:example.com') will mutate to + * get a different display name if Bob later changes his display name + * in the room. + * There are also 'sentinel' members (accessed via getSentinelMember). + * These also represent the state of room members at the point in time + * represented by the RoomState object, but unlike objects from getMember, + * sentinel objects will always represent the room state as at the time + * getSentinelMember was called, so if Bob subsequently changes his display + * name, a room member object previously acquired with getSentinelMember + * will still have his old display name. Calling getSentinelMember again + * after the display name change will return a new RoomMember object + * with Bob's new display name. + * + * @constructor + * @param {?string} roomId Optional. The ID of the room which has this state. + * If none is specified it just tracks paginationTokens, useful for notifTimelineSet + * @param {?object} oobMemberFlags Optional. The state of loading out of bound members. + * As the timeline might get reset while they are loading, this state needs to be inherited + * and shared when the room state is cloned for the new timeline. + * This should only be passed from clone. + * @prop {Object.} members The room member dictionary, keyed + * on the user's ID. + * @prop {Object.>} events The state + * events dictionary, keyed on the event type and then the state_key value. + * @prop {string} paginationToken The pagination token for this state. + */ + constructor(public readonly roomId: string, private oobMemberFlags = { status: OobStatus.NotStarted }) { + super(); + this.updateModifiedTime(); + } + + /** + * Returns the number of joined members in this room + * This method caches the result. + * @return {number} The number of members in this room whose membership is 'join' + */ + public getJoinedMemberCount(): number { + if (this.summaryJoinedMemberCount !== null) { + return this.summaryJoinedMemberCount; + } + if (this.joinedMemberCount === null) { + this.joinedMemberCount = this.getMembers().reduce((count, m) => { + return m.membership === 'join' ? count + 1 : count; + }, 0); + } + return this.joinedMemberCount; + } + + /** + * Set the joined member count explicitly (like from summary part of the sync response) + * @param {number} count the amount of joined members + */ + public setJoinedMemberCount(count: number): void { + this.summaryJoinedMemberCount = count; + } + + /** + * Returns the number of invited members in this room + * @return {number} The number of members in this room whose membership is 'invite' + */ + public getInvitedMemberCount(): number { + if (this.summaryInvitedMemberCount !== null) { + return this.summaryInvitedMemberCount; + } + if (this.invitedMemberCount === null) { + this.invitedMemberCount = this.getMembers().reduce((count, m) => { + return m.membership === 'invite' ? count + 1 : count; + }, 0); + } + return this.invitedMemberCount; + } + + /** + * Set the amount of invited members in this room + * @param {number} count the amount of invited members + */ + public setInvitedMemberCount(count: number): void { + this.summaryInvitedMemberCount = count; + } + + /** + * Get all RoomMembers in this room. + * @return {Array} A list of RoomMembers. + */ + public getMembers(): RoomMember[] { + return Object.values(this.members); + } + + /** + * Get all RoomMembers in this room, excluding the user IDs provided. + * @param {Array} excludedIds The user IDs to exclude. + * @return {Array} A list of RoomMembers. + */ + public getMembersExcept(excludedIds: string[]): RoomMember[] { + return this.getMembers().filter((m) => !excludedIds.includes(m.userId)); + } + + /** + * Get a room member by their user ID. + * @param {string} userId The room member's user ID. + * @return {RoomMember} The member or null if they do not exist. + */ + public getMember(userId: string): RoomMember | null { + return this.members[userId] || null; + } + + /** + * Get a room member whose properties will not change with this room state. You + * typically want this if you want to attach a RoomMember to a MatrixEvent which + * may no longer be represented correctly by Room.currentState or Room.oldState. + * The term 'sentinel' refers to the fact that this RoomMember is an unchanging + * guardian for state at this particular point in time. + * @param {string} userId The room member's user ID. + * @return {RoomMember} The member or null if they do not exist. + */ + public getSentinelMember(userId: string): RoomMember | null { + if (!userId) return null; + let sentinel = this.sentinels[userId]; + + if (sentinel === undefined) { + sentinel = new RoomMember(this.roomId, userId); + const member = this.members[userId]; + if (member) { + sentinel.setMembershipEvent(member.events.member, this); + } + this.sentinels[userId] = sentinel; + } + return sentinel; + } + + /** + * Get state events from the state of the room. + * @param {string} eventType The event type of the state event. + * @param {string} stateKey Optional. The state_key of the state event. If + * this is undefined then all matching state events will be + * returned. + * @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was + * undefined, else a single event (or null if no match found). + */ + public getStateEvents(eventType: string): MatrixEvent[]; + public getStateEvents(eventType: string, stateKey: string): MatrixEvent; + public getStateEvents(eventType: string, stateKey?: string) { + if (!this.events.has(eventType)) { + // no match + return stateKey === undefined ? [] : null; + } + if (stateKey === undefined) { // return all values + return Array.from(this.events.get(eventType).values()); + } + const event = this.events.get(eventType).get(stateKey); + return event ? event : null; + } + + /** + * Creates a copy of this room state so that mutations to either won't affect the other. + * @return {RoomState} the copy of the room state + */ + public clone(): RoomState { + const copy = new RoomState(this.roomId, this.oobMemberFlags); + + // Ugly hack: because setStateEvents will mark + // members as susperseding future out of bound members + // if loading is in progress (through oobMemberFlags) + // since these are not new members, we're merely copying them + // set the status to not started + // after copying, we set back the status + const status = this.oobMemberFlags.status; + this.oobMemberFlags.status = OobStatus.NotStarted; + + Array.from(this.events.values()).forEach((eventsByStateKey) => { + copy.setStateEvents(Array.from(eventsByStateKey.values())); + }); + + // Ugly hack: see above + this.oobMemberFlags.status = status; + + if (this.summaryInvitedMemberCount !== null) { + copy.setInvitedMemberCount(this.getInvitedMemberCount()); + } + if (this.summaryJoinedMemberCount !== null) { + copy.setJoinedMemberCount(this.getJoinedMemberCount()); + } + + // copy out of band flags if needed + if (this.oobMemberFlags.status == OobStatus.Finished) { + // copy markOutOfBand flags + this.getMembers().forEach((member) => { + if (member.isOutOfBand()) { + const copyMember = copy.getMember(member.userId); + copyMember.markOutOfBand(); + } + }); + } + + return copy; + } + + /** + * Add previously unknown state events. + * When lazy loading members while back-paginating, + * the relevant room state for the timeline chunk at the end + * of the chunk can be set with this method. + * @param {MatrixEvent[]} events state events to prepend + */ + public setUnknownStateEvents(events: MatrixEvent[]): void { + const unknownStateEvents = events.filter((event) => { + return !this.events.has(event.getType()) || + !this.events.get(event.getType()).has(event.getStateKey()); + }); + + this.setStateEvents(unknownStateEvents); + } + + /** + * Add an array of one or more state MatrixEvents, overwriting + * any existing state with the same {type, stateKey} tuple. Will fire + * "RoomState.events" for every event added. May fire "RoomState.members" + * if there are m.room.member events. + * @param {MatrixEvent[]} stateEvents a list of state events for this room. + * @fires module:client~MatrixClient#event:"RoomState.members" + * @fires module:client~MatrixClient#event:"RoomState.newMember" + * @fires module:client~MatrixClient#event:"RoomState.events" + */ + public setStateEvents(stateEvents: MatrixEvent[]) { + this.updateModifiedTime(); + + // update the core event dict + stateEvents.forEach((event) => { + if (event.getRoomId() !== this.roomId) { + return; + } + if (!event.isState()) { + return; + } + + const lastStateEvent = this.getStateEventMatching(event); + this.setStateEvent(event); + if (event.getType() === EventType.RoomMember) { + this.updateDisplayNameCache(event.getStateKey(), event.getContent().displayname); + this.updateThirdPartyTokenCache(event); + } + this.emit("RoomState.events", event, this, lastStateEvent); + }); + + // update higher level data structures. This needs to be done AFTER the + // core event dict as these structures may depend on other state events in + // the given array (e.g. disambiguating display names in one go to do both + // clashing names rather than progressively which only catches 1 of them). + stateEvents.forEach((event) => { + if (event.getRoomId() !== this.roomId) { + return; + } + if (!event.isState()) { + return; + } + + if (event.getType() === EventType.RoomMember) { + const userId = event.getStateKey(); + + // leave events apparently elide the displayname or avatar_url, + // so let's fake one up so that we don't leak user ids + // into the timeline + if (event.getContent().membership === "leave" || + event.getContent().membership === "ban") { + event.getContent().avatar_url = + event.getContent().avatar_url || + event.getPrevContent().avatar_url; + event.getContent().displayname = + event.getContent().displayname || + event.getPrevContent().displayname; + } + + const member = this.getOrCreateMember(userId, event); + member.setMembershipEvent(event, this); + + this.updateMember(member); + this.emit("RoomState.members", event, this, member); + } else if (event.getType() === EventType.RoomPowerLevels) { + // events with unknown state keys should be ignored + // and should not aggregate onto members power levels + if (event.getStateKey() !== "") { + return; + } + const members = Object.values(this.members); + members.forEach((member) => { + // We only propagate `RoomState.members` event if the + // power levels has been changed + // large room suffer from large re-rendering especially when not needed + const oldLastModified = member.getLastModifiedTime(); + member.setPowerLevelEvent(event); + if (oldLastModified !== member.getLastModifiedTime()) { + this.emit("RoomState.members", event, this, member); + } + }); + + // assume all our sentinels are now out-of-date + this.sentinels = {}; + } + }); + } + + /** + * Looks up a member by the given userId, and if it doesn't exist, + * create it and emit the `RoomState.newMember` event. + * This method makes sure the member is added to the members dictionary + * before emitting, as this is done from setStateEvents and setOutOfBandMember. + * @param {string} userId the id of the user to look up + * @param {MatrixEvent} event the membership event for the (new) member. Used to emit. + * @fires module:client~MatrixClient#event:"RoomState.newMember" + * @returns {RoomMember} the member, existing or newly created. + */ + private getOrCreateMember(userId: string, event: MatrixEvent): RoomMember { + let member = this.members[userId]; + if (!member) { + member = new RoomMember(this.roomId, userId); + // add member to members before emitting any events, + // as event handlers often lookup the member + this.members[userId] = member; + this.emit("RoomState.newMember", event, this, member); + } + return member; + } + + private setStateEvent(event: MatrixEvent): void { + if (!this.events.has(event.getType())) { + this.events.set(event.getType(), new Map()); + } + this.events.get(event.getType()).set(event.getStateKey(), event); + } + + private getStateEventMatching(event: MatrixEvent): MatrixEvent | null { + if (!this.events.has(event.getType())) return null; + return this.events.get(event.getType()).get(event.getStateKey()); + } + + private updateMember(member: RoomMember): void { + // this member may have a power level already, so set it. + const pwrLvlEvent = this.getStateEvents(EventType.RoomPowerLevels, ""); + if (pwrLvlEvent) { + member.setPowerLevelEvent(pwrLvlEvent); + } + + // blow away the sentinel which is now outdated + delete this.sentinels[member.userId]; + + this.members[member.userId] = member; + this.joinedMemberCount = null; + this.invitedMemberCount = null; + } + + /** + * Get the out-of-band members loading state, whether loading is needed or not. + * Note that loading might be in progress and hence isn't needed. + * @return {boolean} whether or not the members of this room need to be loaded + */ + public needsOutOfBandMembers(): boolean { + return this.oobMemberFlags.status === OobStatus.NotStarted; + } + + /** + * Mark this room state as waiting for out-of-band members, + * ensuring it doesn't ask for them to be requested again + * through needsOutOfBandMembers + */ + public markOutOfBandMembersStarted(): void { + if (this.oobMemberFlags.status !== OobStatus.NotStarted) { + return; + } + this.oobMemberFlags.status = OobStatus.InProgress; + } + + /** + * Mark this room state as having failed to fetch out-of-band members + */ + public markOutOfBandMembersFailed(): void { + if (this.oobMemberFlags.status !== OobStatus.InProgress) { + return; + } + this.oobMemberFlags.status = OobStatus.NotStarted; + } + + /** + * Clears the loaded out-of-band members + */ + public clearOutOfBandMembers(): void { + let count = 0; + Object.keys(this.members).forEach((userId) => { + const member = this.members[userId]; + if (member.isOutOfBand()) { + ++count; + delete this.members[userId]; + } + }); + logger.log(`LL: RoomState removed ${count} members...`); + this.oobMemberFlags.status = OobStatus.NotStarted; + } + + /** + * Sets the loaded out-of-band members. + * @param {MatrixEvent[]} stateEvents array of membership state events + */ + public setOutOfBandMembers(stateEvents: MatrixEvent[]): void { + logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`); + if (this.oobMemberFlags.status !== OobStatus.InProgress) { + return; + } + logger.log(`LL: RoomState put in finished state ...`); + this.oobMemberFlags.status = OobStatus.Finished; + stateEvents.forEach((e) => this.setOutOfBandMember(e)); + } + + /** + * Sets a single out of band member, used by both setOutOfBandMembers and clone + * @param {MatrixEvent} stateEvent membership state event + */ + private setOutOfBandMember(stateEvent: MatrixEvent): void { + if (stateEvent.getType() !== EventType.RoomMember) { + return; + } + const userId = stateEvent.getStateKey(); + const existingMember = this.getMember(userId); + // never replace members received as part of the sync + if (existingMember && !existingMember.isOutOfBand()) { + return; + } + + const member = this.getOrCreateMember(userId, stateEvent); + member.setMembershipEvent(stateEvent, this); + // needed to know which members need to be stored seperately + // as they are not part of the sync accumulator + // this is cleared by setMembershipEvent so when it's updated through /sync + member.markOutOfBand(); + + this.updateDisplayNameCache(member.userId, member.name); + + this.setStateEvent(stateEvent); + this.updateMember(member); + this.emit("RoomState.members", stateEvent, this, member); + } + + /** + * Set the current typing event for this room. + * @param {MatrixEvent} event The typing event + */ + public setTypingEvent(event: MatrixEvent): void { + Object.values(this.members).forEach(function(member) { + member.setTypingEvent(event); + }); + } + + /** + * Get the m.room.member event which has the given third party invite token. + * + * @param {string} token The token + * @return {?MatrixEvent} The m.room.member event or null + */ + public getInviteForThreePidToken(token: string): MatrixEvent | null { + return this.tokenToInvite[token] || null; + } + + /** + * Update the last modified time to the current time. + */ + private updateModifiedTime(): void { + this.modified = Date.now(); + } + + /** + * Get the timestamp when this room state was last updated. This timestamp is + * updated when this object has received new state events. + * @return {number} The timestamp + */ + public getLastModifiedTime(): number { + return this.modified; + } + + /** + * Get user IDs with the specified or similar display names. + * @param {string} displayName The display name to get user IDs from. + * @return {string[]} An array of user IDs or an empty array. + */ + public getUserIdsWithDisplayName(displayName: string): string[] { + return this.displayNameToUserIds[utils.removeHiddenChars(displayName)] || []; + } + + /** + * Returns true if userId is in room, event is not redacted and either sender of + * mxEvent or has power level sufficient to redact events other than their own. + * @param {MatrixEvent} mxEvent The event to test permission for + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given used ID can redact given event + */ + public maySendRedactionForEvent(mxEvent: MatrixEvent, userId: string): boolean { + const member = this.getMember(userId); + if (!member || member.membership === 'leave') return false; + + if (mxEvent.status || mxEvent.isRedacted()) return false; + + // The user may have been the sender, but they can't redact their own message + // if redactions are blocked. + const canRedact = this.maySendEvent(EventType.RoomRedaction, userId); + if (mxEvent.getSender() === userId) return canRedact; + + return this.hasSufficientPowerLevelFor('redact', member.powerLevel); + } + + /** + * Returns true if the given power level is sufficient for action + * @param {string} action The type of power level to check + * @param {number} powerLevel The power level of the member + * @return {boolean} true if the given power level is sufficient + */ + private hasSufficientPowerLevelFor(action: string, powerLevel: number): boolean { + const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ""); + + let powerLevels = {}; + if (powerLevelsEvent) { + powerLevels = powerLevelsEvent.getContent(); + } + + let requiredLevel = 50; + if (utils.isNumber(powerLevels[action])) { + requiredLevel = powerLevels[action]; + } + + return powerLevel >= requiredLevel; + } + + /** + * Short-form for maySendEvent('m.room.message', userId) + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID should be permitted to send + * message events into the given room. + */ + public maySendMessage(userId: string): boolean { + return this.maySendEventOfType(EventType.RoomMessage, userId, false); + } + + /** + * Returns true if the given user ID has permission to send a normal + * event of type `eventType` into this room. + * @param {string} eventType The type of event to test + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID should be permitted to send + * the given type of event into this room, + * according to the room's state. + */ + public maySendEvent(eventType: EventType | string, userId: string): boolean { + return this.maySendEventOfType(eventType, userId, false); + } + + /** + * Returns true if the given MatrixClient has permission to send a state + * event of type `stateEventType` into this room. + * @param {string} stateEventType The type of state events to test + * @param {MatrixClient} cli The client to test permission for + * @return {boolean} true if the given client should be permitted to send + * the given type of state event into this room, + * according to the room's state. + */ + public mayClientSendStateEvent(stateEventType: EventType | string, cli: MatrixClient): boolean { + if (cli.isGuest()) { + return false; + } + return this.maySendStateEvent(stateEventType, cli.credentials.userId); + } + + /** + * Returns true if the given user ID has permission to send a state + * event of type `stateEventType` into this room. + * @param {string} stateEventType The type of state events to test + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID should be permitted to send + * the given type of state event into this room, + * according to the room's state. + */ + public maySendStateEvent(stateEventType: EventType | string, userId: string): boolean { + return this.maySendEventOfType(stateEventType, userId, true); + } + + /** + * Returns true if the given user ID has permission to send a normal or state + * event of type `eventType` into this room. + * @param {string} eventType The type of event to test + * @param {string} userId The user ID of the user to test permission for + * @param {boolean} state If true, tests if the user may send a state + event of this type. Otherwise tests whether + they may send a regular event. + * @return {boolean} true if the given user ID should be permitted to send + * the given type of event into this room, + * according to the room's state. + */ + private maySendEventOfType(eventType: EventType | string, userId: string, state: boolean): boolean { + const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ''); + + let powerLevels; + let eventsLevels = {}; + + let stateDefault = 0; + let eventsDefault = 0; + let powerLevel = 0; + if (powerLevelsEvent) { + powerLevels = powerLevelsEvent.getContent(); + eventsLevels = powerLevels.events || {}; + + if (Number.isFinite(powerLevels.state_default)) { + stateDefault = powerLevels.state_default; + } else { + stateDefault = 50; + } + + const userPowerLevel = powerLevels.users && powerLevels.users[userId]; + if (Number.isFinite(userPowerLevel)) { + powerLevel = userPowerLevel; + } else if (Number.isFinite(powerLevels.users_default)) { + powerLevel = powerLevels.users_default; + } + + if (Number.isFinite(powerLevels.events_default)) { + eventsDefault = powerLevels.events_default; + } + } + + let requiredLevel = state ? stateDefault : eventsDefault; + if (Number.isFinite(eventsLevels[eventType])) { + requiredLevel = eventsLevels[eventType]; + } + return powerLevel >= requiredLevel; + } + + /** + * Returns true if the given user ID has permission to trigger notification + * of type `notifLevelKey` + * @param {string} notifLevelKey The level of notification to test (eg. 'room') + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID has permission to trigger a + * notification of this type. + */ + public mayTriggerNotifOfType(notifLevelKey: string, userId: string): boolean { + const member = this.getMember(userId); + if (!member) { + return false; + } + + const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ''); + + let notifLevel = 50; + if ( + powerLevelsEvent && + powerLevelsEvent.getContent() && + powerLevelsEvent.getContent().notifications && + utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey]) + ) { + notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey]; + } + + return member.powerLevel >= notifLevel; + } + + /** + * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. + * @returns {string} the join_rule applied to this room + */ + public getJoinRule(): string { + const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, ""); + const joinRuleContent = joinRuleEvent ? joinRuleEvent.getContent() : {}; + return joinRuleContent["join_rule"] || "invite"; + } + + private updateThirdPartyTokenCache(memberEvent: MatrixEvent): void { + if (!memberEvent.getContent().third_party_invite) { + return; + } + const token = (memberEvent.getContent().third_party_invite.signed || {}).token; + if (!token) { + return; + } + const threePidInvite = this.getStateEvents(EventType.RoomThirdPartyInvite, token); + if (!threePidInvite) { + return; + } + this.tokenToInvite[token] = memberEvent; + } + + private updateDisplayNameCache(userId: string, displayName: string): void { + const oldName = this.userIdsToDisplayNames[userId]; + delete this.userIdsToDisplayNames[userId]; + if (oldName) { + // Remove the old name from the cache. + // We clobber the user_id > name lookup but the name -> [user_id] lookup + // means we need to remove that user ID from that array rather than nuking + // the lot. + const strippedOldName = utils.removeHiddenChars(oldName); + + const existingUserIds = this.displayNameToUserIds[strippedOldName]; + if (existingUserIds) { + // remove this user ID from this array + const filteredUserIDs = existingUserIds.filter((id) => id !== userId); + this.displayNameToUserIds[strippedOldName] = filteredUserIDs; + } + } + + this.userIdsToDisplayNames[userId] = displayName; + + const strippedDisplayname = displayName && utils.removeHiddenChars(displayName); + // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js + if (strippedDisplayname) { + if (!this.displayNameToUserIds[strippedDisplayname]) { + this.displayNameToUserIds[strippedDisplayname] = []; + } + this.displayNameToUserIds[strippedDisplayname].push(userId); + } + } +} + +/** + * Fires whenever the event dictionary in room state is updated. + * @event module:client~MatrixClient#"RoomState.events" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomState} state The room state whose RoomState.events dictionary + * was updated. + * @param {MatrixEvent} prevEvent The event being replaced by the new state, if + * known. Note that this can differ from `getPrevContent()` on the new state event + * as this is the store's view of the last state, not the previous state provided + * by the server. + * @example + * matrixClient.on("RoomState.events", function(event, state, prevEvent){ + * var newStateEvent = event; + * }); + */ + +/** + * Fires whenever a member in the members dictionary is updated in any way. + * @event module:client~MatrixClient#"RoomState.members" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomState} state The room state whose RoomState.members dictionary + * was updated. + * @param {RoomMember} member The room member that was updated. + * @example + * matrixClient.on("RoomState.members", function(event, state, member){ + * var newMembershipState = member.membership; + * }); + */ + +/** + * Fires whenever a member is added to the members dictionary. The RoomMember + * will not be fully populated yet (e.g. no membership state) but will already + * be available in the members dictionary. + * @event module:client~MatrixClient#"RoomState.newMember" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomState} state The room state whose RoomState.members dictionary + * was updated with a new entry. + * @param {RoomMember} member The room member that was added. + * @example + * matrixClient.on("RoomState.newMember", function(event, state, member){ + * // add event listeners on 'member' + * }); + */ diff --git a/src/models/room-summary.ts b/src/models/room-summary.ts index f8327f798..d01b470ae 100644 --- a/src/models/room-summary.ts +++ b/src/models/room-summary.ts @@ -18,12 +18,18 @@ limitations under the License. * @module models/room-summary */ +export interface IRoomSummary { + "m.heroes": string[]; + "m.joined_member_count": number; + "m.invited_member_count": number; +} + interface IInfo { title: string; - desc: string; - numMembers: number; - aliases: string[]; - timestamp: number; + desc?: string; + numMembers?: number; + aliases?: string[]; + timestamp?: number; } /** diff --git a/src/models/room.js b/src/models/room.js deleted file mode 100644 index 2e4229c7f..000000000 --- a/src/models/room.js +++ /dev/null @@ -1,2254 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018, 2019 New Vector Ltd -Copyright 2019 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 models/room - */ - -import { EventEmitter } from "events"; -import { EventTimelineSet } from "./event-timeline-set"; -import { EventTimeline } from "./event-timeline"; -import { getHttpUriForMxc } from "../content-repo"; -import * as utils from "../utils"; -import { EventStatus, MatrixEvent } from "./event"; -import { RoomMember } from "./room-member"; -import { RoomSummary } from "./room-summary"; -import { logger } from '../logger'; -import { ReEmitter } from '../ReEmitter'; -import { EventType, RoomCreateTypeField, RoomType } from "../@types/event"; -import { normalize } from "../utils"; - -// These constants are used as sane defaults when the homeserver doesn't support -// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be -// the same as the common default room version whereas SAFE_ROOM_VERSIONS are the -// room versions which are considered okay for people to run without being asked -// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers -// return an m.room_versions capability. -const KNOWN_SAFE_ROOM_VERSION = '6'; -const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6']; - -function synthesizeReceipt(userId, event, receiptType) { - // console.log("synthesizing receipt for "+event.getId()); - // This is really ugly because JS has no way to express an object literal - // where the name of a key comes from an expression - const fakeReceipt = { - content: {}, - type: "m.receipt", - room_id: event.getRoomId(), - }; - fakeReceipt.content[event.getId()] = {}; - fakeReceipt.content[event.getId()][receiptType] = {}; - fakeReceipt.content[event.getId()][receiptType][userId] = { - ts: event.getTs(), - }; - return new MatrixEvent(fakeReceipt); -} - -/** - * Construct a new Room. - * - *

For a room, we store an ordered sequence of timelines, which may or may not - * be continuous. Each timeline lists a series of events, as well as tracking - * the room state at the start and the end of the timeline. It also tracks - * forward and backward pagination tokens, as well as containing links to the - * next timeline in the sequence. - * - *

There is one special timeline - the 'live' timeline, which represents the - * timeline to which events are being added in real-time as they are received - * from the /sync API. Note that you should not retain references to this - * timeline - even if it is the current timeline right now, it may not remain - * so if the server gives us a timeline gap in /sync. - * - *

In order that we can find events from their ids later, we also maintain a - * map from event_id to timeline and index. - * - * @constructor - * @alias module:models/room - * @param {string} roomId Required. The ID of this room. - * @param {MatrixClient} client Required. The client, used to lazy load members. - * @param {string} myUserId Required. The ID of the syncing user. - * @param {Object=} opts Configuration options - * @param {*} opts.storageToken Optional. The token which a data store can use - * to remember the state of the room. What this means is dependent on the store - * implementation. - * - * @param {String=} opts.pendingEventOrdering Controls where pending messages - * appear in a room's timeline. If "chronological", messages will appear - * in the timeline when the call to sendEvent was made. If - * "detached", pending messages will appear in a separate list, - * accessbile via {@link module:models/room#getPendingEvents}. Default: - * "chronological". - * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved - * timeline support. - * @param {boolean} [opts.unstableClientRelationAggregation = false] - * Optional. Set to true to enable client-side aggregation of event relations - * via `EventTimelineSet#getRelationsForEvent`. - * This feature is currently unstable and the API may change without notice. - * - * @prop {string} roomId The ID of this room. - * @prop {string} name The human-readable display name for this room. - * @prop {string} normalizedName The unhomoglyphed name for this room. - * @prop {Array} timeline The live event timeline for this room, - * with the oldest event at index 0. Present for backwards compatibility - - * prefer getLiveTimeline().getEvents(). - * @prop {object} tags Dict of room tags; the keys are the tag name and the values - * are any metadata associated with the tag - e.g. { "fav" : { order: 1 } } - * @prop {object} accountData Dict of per-room account_data events; the keys are the - * event type and the values are the events. - * @prop {RoomState} oldState The state of the room at the time of the oldest - * event in the live timeline. Present for backwards compatibility - - * prefer getLiveTimeline().getState(EventTimeline.BACKWARDS). - * @prop {RoomState} currentState The state of the room at the time of the - * newest event in the timeline. Present for backwards compatibility - - * prefer getLiveTimeline().getState(EventTimeline.FORWARDS). - * @prop {RoomSummary} summary The room summary. - * @prop {*} storageToken A token which a data store can use to remember - * the state of the room. - */ -export function Room(roomId, client, myUserId, opts) { - opts = opts || {}; - opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; - - this._client = client; - - // In some cases, we add listeners for every displayed Matrix event, so it's - // common to have quite a few more than the default limit. - this.setMaxListeners(100); - - this.reEmitter = new ReEmitter(this); - - if (["chronological", "detached"].indexOf(opts.pendingEventOrdering) === -1) { - throw new Error( - "opts.pendingEventOrdering MUST be either 'chronological' or " + - "'detached'. Got: '" + opts.pendingEventOrdering + "'", - ); - } - - this.myUserId = myUserId; - this.roomId = roomId; - this.name = roomId; - this.tags = { - // $tagName: { $metadata: $value }, - // $tagName: { $metadata: $value }, - }; - this.accountData = { - // $eventType: $event - }; - this.summary = null; - this.storageToken = opts.storageToken; - this._opts = opts; - this._txnToEvent = {}; // Pending in-flight requests { string: MatrixEvent } - // receipts should clobber based on receipt_type and user_id pairs hence - // the form of this structure. This is sub-optimal for the exposed APIs - // which pass in an event ID and get back some receipts, so we also store - // a pre-cached list for this purpose. - this._receipts = { - // receipt_type: { - // user_id: { - // eventId: , - // data: - // } - // } - }; - this._receiptCacheByEventId = { - // $event_id: [{ - // type: $type, - // userId: $user_id, - // data: - // }] - }; - // only receipts that came from the server, not synthesized ones - this._realReceipts = {}; - - this._notificationCounts = {}; - - // all our per-room timeline sets. the first one is the unfiltered ones; - // the subsequent ones are the filtered ones in no particular order. - this._timelineSets = [new EventTimelineSet(this, opts)]; - this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), - ["Room.timeline", "Room.timelineReset"]); - - this._fixUpLegacyTimelineFields(); - - // any filtered timeline sets we're maintaining for this room - this._filteredTimelineSets = { - // filter_id: timelineSet - }; - - if (this._opts.pendingEventOrdering == "detached") { - this._pendingEventList = []; - 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); - } - event.setStatus(EventStatus.NOT_SENT); - this.addPendingEvent(event, event.getTxnId()); - }); - } - } - - // read by megolm; boolean value - null indicates "use global value" - this._blacklistUnverifiedDevices = null; - this._selfMembership = null; - this._summaryHeroes = null; - // awaited by getEncryptionTargetMembers while room members are loading - - if (!this._opts.lazyLoadMembers) { - this._membersPromise = Promise.resolve(); - } else { - this._membersPromise = null; - } - - // flags to stop logspam about missing m.room.create events - this.getTypeWarning = false; - this.getVersionWarning = false; -} - -/** - * @param {string} roomId ID of the current room - * @returns {string} Storage key to retrieve pending events - */ -function pendingEventsKey(roomId) { - return `mx_pending_events_${roomId}`; -} - -utils.inherits(Room, EventEmitter); - -/** - * Bulk decrypt critical events in a room - * - * Critical events represents the minimal set of events to decrypt - * for a typical UI to function properly - * - * - Last event of every room (to generate likely message preview) - * - All events up to the read receipt (to calculate an accurate notification count) - * - * @returns {Promise} Signals when all events have been decrypted - */ -Room.prototype.decryptCriticalEvents = function() { - const readReceiptEventId = this.getEventReadUpTo(this._client.getUserId(), true); - const events = this.getLiveTimeline().getEvents(); - const readReceiptTimelineIndex = events.findIndex(matrixEvent => { - return matrixEvent.event.event_id === readReceiptEventId; - }); - - const decryptionPromises = events - .slice(readReceiptTimelineIndex) - .filter(event => event.shouldAttemptDecryption()) - .reverse() - .map(event => event.attemptDecryption(this._client.crypto, { isRetry: true })); - - return Promise.allSettled(decryptionPromises); -}; - -/** - * Bulk decrypt events in a room - * - * @returns {Promise} Signals when all events have been decrypted - */ -Room.prototype.decryptAllEvents = function() { - const decryptionPromises = this - .getUnfilteredTimelineSet() - .getLiveTimeline() - .getEvents() - .filter(event => event.shouldAttemptDecryption()) - .reverse() - .map(event => event.attemptDecryption(this._client.crypto, { isRetry: true })); - - return Promise.allSettled(decryptionPromises); -}; - -/** - * Gets the version of the room - * @returns {string} The version of the room, or null if it could not be determined - */ -Room.prototype.getVersion = function() { - const createEvent = this.currentState.getStateEvents("m.room.create", ""); - if (!createEvent) { - if (!this.getVersionWarning) { - logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event"); - this.getVersionWarning = true; - } - return '1'; - } - const ver = createEvent.getContent()['room_version']; - if (ver === undefined) return '1'; - return ver; -}; - -/** - * Determines whether this room needs to be upgraded to a new version - * @returns {string?} What version the room should be upgraded to, or null if - * the room does not require upgrading at this time. - * @deprecated Use #getRecommendedVersion() instead - */ -Room.prototype.shouldUpgradeToVersion = function() { - // TODO: Remove this function. - // This makes assumptions about which versions are safe, and can easily - // be wrong. Instead, people are encouraged to use getRecommendedVersion - // which determines a safer value. This function doesn't use that function - // because this is not async-capable, and to avoid breaking the contract - // we're deprecating this. - - if (!SAFE_ROOM_VERSIONS.includes(this.getVersion())) { - return KNOWN_SAFE_ROOM_VERSION; - } - - return null; -}; - -/** - * Determines the recommended room version for the room. This returns an - * object with 3 properties: version as the new version the - * room should be upgraded to (may be the same as the current version); - * needsUpgrade to indicate if the room actually can be - * upgraded (ie: does the current version not match?); and urgent - * to indicate if the new version patches a vulnerability in a previous - * version. - * @returns {Promise<{version: string, needsUpgrade: bool, urgent: bool}>} - * Resolves to the version the room should be upgraded to. - */ -Room.prototype.getRecommendedVersion = async function() { - const capabilities = await this._client.getCapabilities(); - let versionCap = capabilities["m.room_versions"]; - if (!versionCap) { - versionCap = { - default: KNOWN_SAFE_ROOM_VERSION, - available: {}, - }; - for (const safeVer of SAFE_ROOM_VERSIONS) { - versionCap.available[safeVer] = "stable"; - } - } - - let result = this._checkVersionAgainstCapability(versionCap); - if (result.urgent && result.needsUpgrade) { - // Something doesn't feel right: we shouldn't need to update - // because the version we're on should be in the protocol's - // namespace. This usually means that the server was updated - // before the client was, making us think the newest possible - // room version is not stable. As a solution, we'll refresh - // the capability we're using to determine this. - logger.warn( - "Refreshing room version capability because the server looks " + - "to be supporting a newer room version we don't know about.", - ); - - const caps = await this._client.getCapabilities(true); - versionCap = caps["m.room_versions"]; - if (!versionCap) { - logger.warn("No room version capability - assuming upgrade required."); - return result; - } else { - result = this._checkVersionAgainstCapability(versionCap); - } - } - - return result; -}; - -Room.prototype._checkVersionAgainstCapability = function(versionCap) { - const currentVersion = this.getVersion(); - logger.log(`[${this.roomId}] Current version: ${currentVersion}`); - logger.log(`[${this.roomId}] Version capability: `, versionCap); - - const result = { - version: currentVersion, - needsUpgrade: false, - urgent: false, - }; - - // If the room is on the default version then nothing needs to change - if (currentVersion === versionCap.default) return result; - - const stableVersions = Object.keys(versionCap.available) - .filter((v) => versionCap.available[v] === 'stable'); - - // Check if the room is on an unstable version. We determine urgency based - // off the version being in the Matrix spec namespace or not (if the version - // is in the current namespace and unstable, the room is probably vulnerable). - if (!stableVersions.includes(currentVersion)) { - result.version = versionCap.default; - result.needsUpgrade = true; - result.urgent = !!this.getVersion().match(/^[0-9]+[0-9.]*$/g); - if (result.urgent) { - logger.warn(`URGENT upgrade required on ${this.roomId}`); - } else { - logger.warn(`Non-urgent upgrade required on ${this.roomId}`); - } - return result; - } - - // The room is on a stable, but non-default, version by this point. - // No upgrade needed. - return result; -}; - -/** - * Determines whether the given user is permitted to perform a room upgrade - * @param {String} userId The ID of the user to test against - * @returns {bool} True if the given user is permitted to upgrade the room - */ -Room.prototype.userMayUpgradeRoom = function(userId) { - return this.currentState.maySendStateEvent("m.room.tombstone", userId); -}; - -/** - * Get the list of pending sent events for this room - * - * @return {module:models/event.MatrixEvent[]} A list of the sent events - * waiting for remote echo. - * - * @throws If opts.pendingEventOrdering was not 'detached' - */ -Room.prototype.getPendingEvents = function() { - if (this._opts.pendingEventOrdering !== "detached") { - throw new Error( - "Cannot call getPendingEvents with pendingEventOrdering == " + - this._opts.pendingEventOrdering); - } - - return this._pendingEventList; -}; - -/** - * Removes a pending event for this room - * - * @param {string} eventId - * @return {boolean} True if an element was removed. - */ -Room.prototype.removePendingEvent = function(eventId) { - if (this._opts.pendingEventOrdering !== "detached") { - throw new Error( - "Cannot call removePendingEvent with pendingEventOrdering == " + - this._opts.pendingEventOrdering); - } - - const removed = utils.removeElement( - this._pendingEventList, - function(ev) { - return ev.getId() == eventId; - }, false, - ); - - this._savePendingEvents(); - - return removed; -}; - -/** - * Check whether the pending event list contains a given event by ID. - * If pending event ordering is not "detached" then this returns false. - * - * @param {string} eventId The event ID to check for. - * @return {boolean} - */ -Room.prototype.hasPendingEvent = function(eventId) { - if (this._opts.pendingEventOrdering !== "detached") { - return false; - } - - return this._pendingEventList.some(event => event.getId() === eventId); -}; - -/** - * Get a specific event from the pending event list, if configured, null otherwise. - * - * @param {string} eventId The event ID to check for. - * @return {MatrixEvent} - */ -Room.prototype.getPendingEvent = function(eventId) { - if (this._opts.pendingEventOrdering !== "detached") { - return null; - } - - return this._pendingEventList.find(event => event.getId() === eventId); -}; - -/** - * Get the live unfiltered timeline for this room. - * - * @return {module:models/event-timeline~EventTimeline} live timeline - */ -Room.prototype.getLiveTimeline = function() { - return this.getUnfilteredTimelineSet().getLiveTimeline(); -}; - -/** - * Get the timestamp of the last message in the room - * - * @return {number} the timestamp of the last message in the room - */ -Room.prototype.getLastActiveTimestamp = function() { - const timeline = this.getLiveTimeline(); - const events = timeline.getEvents(); - if (events.length) { - const lastEvent = events[events.length - 1]; - return lastEvent.getTs(); - } else { - return Number.MIN_SAFE_INTEGER; - } -}; - -/** - * @param {string} myUserId the user id for the logged in member - * @return {string} the membership type (join | leave | invite) for the logged in user - */ -Room.prototype.getMyMembership = function() { - return this._selfMembership; -}; - -/** - * If this room is a DM we're invited to, - * try to find out who invited us - * @return {string} user id of the inviter - */ -Room.prototype.getDMInviter = function() { - if (this.myUserId) { - const me = this.getMember(this.myUserId); - if (me) { - return me.getDMInviter(); - } - } - if (this._selfMembership === "invite") { - // fall back to summary information - const memberCount = this.getInvitedAndJoinedMemberCount(); - if (memberCount == 2 && this._summaryHeroes.length) { - return this._summaryHeroes[0]; - } - } -}; - -/** - * Assuming this room is a DM room, tries to guess with which user. - * @return {string} user id of the other member (could be syncing user) - */ -Room.prototype.guessDMUserId = function() { - const me = this.getMember(this.myUserId); - if (me) { - const inviterId = me.getDMInviter(); - if (inviterId) { - return inviterId; - } - } - // remember, we're assuming this room is a DM, - // so returning the first member we find should be fine - const hasHeroes = Array.isArray(this._summaryHeroes) && - this._summaryHeroes.length; - if (hasHeroes) { - return this._summaryHeroes[0]; - } - const members = this.currentState.getMembers(); - const anyMember = members.find((m) => m.userId !== this.myUserId); - if (anyMember) { - return anyMember.userId; - } - // it really seems like I'm the only user in the room - // so I probably created a room with just me in it - // and marked it as a DM. Ok then - return this.myUserId; -}; - -Room.prototype.getAvatarFallbackMember = function() { - const memberCount = this.getInvitedAndJoinedMemberCount(); - if (memberCount > 2) { - return; - } - const hasHeroes = Array.isArray(this._summaryHeroes) && - this._summaryHeroes.length; - if (hasHeroes) { - const availableMember = this._summaryHeroes.map((userId) => { - return this.getMember(userId); - }).find((member) => !!member); - if (availableMember) { - return availableMember; - } - } - const members = this.currentState.getMembers(); - // could be different than memberCount - // as this includes left members - if (members.length <= 2) { - const availableMember = members.find((m) => { - return m.userId !== this.myUserId; - }); - if (availableMember) { - return availableMember; - } - } - // if all else fails, try falling back to a user, - // and create a one-off member for it - if (hasHeroes) { - const availableUser = this._summaryHeroes.map((userId) => { - return this._client.getUser(userId); - }).find((user) => !!user); - if (availableUser) { - const member = new RoomMember( - this.roomId, availableUser.userId); - member.user = availableUser; - return member; - } - } -}; - -/** - * Sets the membership this room was received as during sync - * @param {string} membership join | leave | invite - */ -Room.prototype.updateMyMembership = function(membership) { - const prevMembership = this._selfMembership; - this._selfMembership = membership; - if (prevMembership !== membership) { - if (membership === "leave") { - this._cleanupAfterLeaving(); - } - this.emit("Room.myMembership", this, membership, prevMembership); - } -}; - -Room.prototype._loadMembersFromServer = async function() { - const lastSyncToken = this._client.store.getSyncToken(); - const queryString = utils.encodeParams({ - not_membership: "leave", - at: lastSyncToken, - }); - const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, - { $roomId: this.roomId }); - const http = this._client.http; - const response = await http.authedRequest(undefined, "GET", path); - return response.chunk; -}; - -Room.prototype._loadMembers = async function() { - // were the members loaded from the server? - let fromServer = false; - let rawMembersEvents = - await this._client.store.getOutOfBandMembers(this.roomId); - if (rawMembersEvents === null) { - fromServer = true; - rawMembersEvents = await this._loadMembersFromServer(); - logger.log(`LL: got ${rawMembersEvents.length} ` + - `members from server for room ${this.roomId}`); - } - const memberEvents = rawMembersEvents.map(this._client.getEventMapper()); - return { memberEvents, fromServer }; -}; - -/** - * Preloads the member list in case lazy loading - * of memberships is in use. Can be called multiple times, - * it will only preload once. - * @return {Promise} when preloading is done and - * accessing the members on the room will take - * all members in the room into account - */ -Room.prototype.loadMembersIfNeeded = function() { - if (this._membersPromise) { - return this._membersPromise; - } - - // mark the state so that incoming messages while - // the request is in flight get marked as superseding - // the OOB members - this.currentState.markOutOfBandMembersStarted(); - - const inMemoryUpdate = this._loadMembers().then((result) => { - 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); - } - return result.fromServer; - }).catch((err) => { - // allow retries on fail - this._membersPromise = null; - this.currentState.markOutOfBandMembersFailed(); - throw err; - }); - // update members in storage, but don't wait for it - inMemoryUpdate.then((fromServer) => { - if (fromServer) { - const oobMembers = this.currentState.getMembers() - .filter((m) => m.isOutOfBand()) - .map((m) => m.events.member.event); - logger.log(`LL: telling store to write ${oobMembers.length}` - + ` members for room ${this.roomId}`); - const store = this._client.store; - return store.setOutOfBandMembers(this.roomId, oobMembers) - // swallow any IDB error as we don't want to fail - // because of this - .catch((err) => { - logger.log("LL: storing OOB room members failed, oh well", - err); - }); - } - }).catch((err) => { - // as this is not awaited anywhere, - // at least show the error in the console - logger.error(err); - }); - - this._membersPromise = inMemoryUpdate; - - return this._membersPromise; -}; - -/** - * Removes the lazily loaded members from storage if needed - */ -Room.prototype.clearLoadedMembersIfNeeded = async function() { - if (this._opts.lazyLoadMembers && this._membersPromise) { - await this.loadMembersIfNeeded(); - await this._client.store.clearOutOfBandMembers(this.roomId); - this.currentState.clearOutOfBandMembers(); - this._membersPromise = null; - } -}; - -/** - * called when sync receives this room in the leave section - * to do cleanup after leaving a room. Possibly called multiple times. - */ -Room.prototype._cleanupAfterLeaving = function() { - this.clearLoadedMembersIfNeeded().catch((err) => { - logger.error(`error after clearing loaded members from ` + - `room ${this.roomId} after leaving`); - logger.log(err); - }); -}; - -/** - * Reset the live timeline of all timelineSets, and start new ones. - * - *

This is used when /sync returns a 'limited' timeline. - * - * @param {string=} backPaginationToken token for back-paginating the new timeline - * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, - * if absent or null, all timelines are reset, removing old ones (including the previous live - * timeline which would otherwise be unable to paginate forwards without this token). - * Removing just the old live timeline whilst preserving previous ones is not supported. - */ -Room.prototype.resetLiveTimeline = function(backPaginationToken, forwardPaginationToken) { - for (let i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[i].resetLiveTimeline( - backPaginationToken, forwardPaginationToken, - ); - } - - this._fixUpLegacyTimelineFields(); -}; - -/** - * Fix up this.timeline, this.oldState and this.currentState - * - * @private - */ -Room.prototype._fixUpLegacyTimelineFields = function() { - // maintain this.timeline as a reference to the live timeline, - // and this.oldState and this.currentState as references to the - // state at the start and end of that timeline. These are more - // for backwards-compatibility than anything else. - this.timeline = this.getLiveTimeline().getEvents(); - this.oldState = this.getLiveTimeline() - .getState(EventTimeline.BACKWARDS); - this.currentState = this.getLiveTimeline() - .getState(EventTimeline.FORWARDS); -}; - -/** - * Returns whether there are any devices in the room that are unverified - * - * Note: Callers should first check if crypto is enabled on this device. If it is - * disabled, then we aren't tracking room devices at all, so we can't answer this, and an - * error will be thrown. - * - * @return {bool} the result - */ -Room.prototype.hasUnverifiedDevices = async function() { - if (!this._client.isRoomEncrypted(this.roomId)) { - return false; - } - const e2eMembers = await this.getEncryptionTargetMembers(); - for (const member of e2eMembers) { - const devices = this._client.getStoredDevicesForUser(member.userId); - if (devices.some((device) => device.isUnverified())) { - return true; - } - } - return false; -}; - -/** - * Return the timeline sets for this room. - * @return {EventTimelineSet[]} array of timeline sets for this room - */ -Room.prototype.getTimelineSets = function() { - return this._timelineSets; -}; - -/** - * Helper to return the main unfiltered timeline set for this room - * @return {EventTimelineSet} room's unfiltered timeline set - */ -Room.prototype.getUnfilteredTimelineSet = function() { - return this._timelineSets[0]; -}; - -/** - * Get the timeline which contains the given event from the unfiltered set, if any - * - * @param {string} eventId event ID to look for - * @return {?module:models/event-timeline~EventTimeline} timeline containing - * the given event, or null if unknown - */ -Room.prototype.getTimelineForEvent = function(eventId) { - return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId); -}; - -/** - * Add a new timeline to this room's unfiltered timeline set - * - * @return {module:models/event-timeline~EventTimeline} newly-created timeline - */ -Room.prototype.addTimeline = function() { - return this.getUnfilteredTimelineSet().addTimeline(); -}; - -/** - * Get an event which is stored in our unfiltered timeline set - * - * @param {string} eventId event ID to look for - * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown - */ -Room.prototype.findEventById = function(eventId) { - return this.getUnfilteredTimelineSet().findEventById(eventId); -}; - -/** - * Get one of the notification counts for this room - * @param {String} type The type of notification count to get. default: 'total' - * @return {Number} The notification count, or undefined if there is no count - * for this type. - */ -Room.prototype.getUnreadNotificationCount = function(type) { - type = type || 'total'; - return this._notificationCounts[type]; -}; - -/** - * Set one of the notification counts for this room - * @param {String} type The type of notification count to set. - * @param {Number} count The new count - */ -Room.prototype.setUnreadNotificationCount = function(type, count) { - this._notificationCounts[type] = count; -}; - -Room.prototype.setSummary = function(summary) { - const heroes = summary["m.heroes"]; - const joinedCount = summary["m.joined_member_count"]; - const invitedCount = summary["m.invited_member_count"]; - if (Number.isInteger(joinedCount)) { - this.currentState.setJoinedMemberCount(joinedCount); - } - if (Number.isInteger(invitedCount)) { - this.currentState.setInvitedMemberCount(invitedCount); - } - if (Array.isArray(heroes)) { - // be cautious about trusting server values, - // and make sure heroes doesn't contain our own id - // just to be sure - this._summaryHeroes = heroes.filter((userId) => { - return userId !== this.myUserId; - }); - } -}; - -/** - * Whether to send encrypted messages to devices within this room. - * @param {Boolean} value true to blacklist unverified devices, null - * to use the global value for this room. - */ -Room.prototype.setBlacklistUnverifiedDevices = function(value) { - this._blacklistUnverifiedDevices = value; -}; - -/** - * Whether to send encrypted messages to devices within this room. - * @return {Boolean} true if blacklisting unverified devices, null - * if the global value should be used for this room. - */ -Room.prototype.getBlacklistUnverifiedDevices = function() { - return this._blacklistUnverifiedDevices; -}; - -/** - * Get the avatar URL for a room if one was set. - * @param {String} baseUrl The homeserver base URL. See - * {@link module:client~MatrixClient#getHomeserverUrl}. - * @param {Number} width The desired width of the thumbnail. - * @param {Number} height The desired height of the thumbnail. - * @param {string} resizeMethod The thumbnail resize method to use, either - * "crop" or "scale". - * @param {boolean} allowDefault True to allow an identicon for this room if an - * avatar URL wasn't explicitly set. Default: true. (Deprecated) - * @return {?string} the avatar URL or null. - */ -Room.prototype.getAvatarUrl = function(baseUrl, width, height, resizeMethod, - allowDefault) { - const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, ""); - if (allowDefault === undefined) { - allowDefault = true; - } - if (!roomAvatarEvent && !allowDefault) { - return null; - } - - const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null; - if (mainUrl) { - return getHttpUriForMxc( - baseUrl, mainUrl, width, height, resizeMethod, - ); - } - - return null; -}; - -/** - * Get the mxc avatar url for the room, if one was set. - * @return {string} the mxc avatar url or falsy - */ -Room.prototype.getMxcAvatarUrl = function() { - const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, ""); - return roomAvatarEvent ? roomAvatarEvent.getContent().url : null; -}; - -/** - * Get the aliases this room has according to the room's state - * The aliases returned by this function may not necessarily - * still point to this room. - * @return {array} The room's alias as an array of strings - */ -Room.prototype.getAliases = function() { - const aliasStrings = []; - - const aliasEvents = this.currentState.getStateEvents("m.room.aliases"); - if (aliasEvents) { - for (let i = 0; i < aliasEvents.length; ++i) { - const aliasEvent = aliasEvents[i]; - if (Array.isArray(aliasEvent.getContent().aliases)) { - const filteredAliases = aliasEvent.getContent().aliases.filter(a => { - if (typeof(a) !== "string") return false; - if (a[0] !== '#') return false; - if (!a.endsWith(`:${aliasEvent.getStateKey()}`)) return false; - - // It's probably valid by here. - return true; - }); - Array.prototype.push.apply(aliasStrings, filteredAliases); - } - } - } - return aliasStrings; -}; - -/** - * Get this room's canonical alias - * The alias returned by this function may not necessarily - * still point to this room. - * @return {?string} The room's canonical alias, or null if there is none - */ -Room.prototype.getCanonicalAlias = function() { - const canonicalAlias = this.currentState.getStateEvents("m.room.canonical_alias", ""); - if (canonicalAlias) { - return canonicalAlias.getContent().alias || null; - } - return null; -}; - -/** - * Get this room's alternative aliases - * @return {array} The room's alternative aliases, or an empty array - */ -Room.prototype.getAltAliases = function() { - const canonicalAlias = this.currentState.getStateEvents("m.room.canonical_alias", ""); - if (canonicalAlias) { - return canonicalAlias.getContent().alt_aliases || []; - } - return []; -}; - -/** - * Add events to a timeline - * - *

Will fire "Room.timeline" for each event added. - * - * @param {MatrixEvent[]} events A list of events to add. - * - * @param {boolean} toStartOfTimeline True to add these events to the start - * (oldest) instead of the end (newest) of the timeline. If true, the oldest - * event will be the last element of 'events'. - * - * @param {module:models/event-timeline~EventTimeline} timeline timeline to - * add events to. - * - * @param {string=} paginationToken token for the next batch of events - * - * @fires module:client~MatrixClient#event:"Room.timeline" - * - */ -Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, - timeline, paginationToken) { - timeline.getTimelineSet().addEventsToTimeline( - events, toStartOfTimeline, - timeline, paginationToken, - ); -}; - -/** - * Get a member from the current room state. - * @param {string} userId The user ID of the member. - * @return {RoomMember} The member or null. - */ - Room.prototype.getMember = function(userId) { - return this.currentState.getMember(userId); - }; - -/** - * Get all currently loaded members from the current - * room state. - * @returns {RoomMember[]} Room members - */ -Room.prototype.getMembers = function() { - return this.currentState.getMembers(); -}; - -/** - * Get a list of members whose membership state is "join". - * @return {RoomMember[]} A list of currently joined members. - */ - Room.prototype.getJoinedMembers = function() { - return this.getMembersWithMembership("join"); - }; - -/** - * Returns the number of joined members in this room - * This method caches the result. - * This is a wrapper around the method of the same name in roomState, returning - * its result for the room's current state. - * @return {integer} The number of members in this room whose membership is 'join' - */ -Room.prototype.getJoinedMemberCount = function() { - return this.currentState.getJoinedMemberCount(); -}; - -/** - * Returns the number of invited members in this room - * @return {integer} The number of members in this room whose membership is 'invite' - */ -Room.prototype.getInvitedMemberCount = function() { - return this.currentState.getInvitedMemberCount(); -}; - -/** - * Returns the number of invited + joined members in this room - * @return {integer} The number of members in this room whose membership is 'invite' or 'join' - */ -Room.prototype.getInvitedAndJoinedMemberCount = function() { - return this.getInvitedMemberCount() + this.getJoinedMemberCount(); -}; - -/** - * Get a list of members with given membership state. - * @param {string} membership The membership state. - * @return {RoomMember[]} A list of members with the given membership state. - */ - Room.prototype.getMembersWithMembership = function(membership) { - return this.currentState.getMembers().filter(function(m) { - return m.membership === membership; - }); - }; - - /** - * Get a list of members we should be encrypting for in this room - * @return {Promise} A list of members who - * we should encrypt messages for in this room. - */ - Room.prototype.getEncryptionTargetMembers = async function() { - await this.loadMembersIfNeeded(); - let members = this.getMembersWithMembership("join"); - if (this.shouldEncryptForInvitedMembers()) { - members = members.concat(this.getMembersWithMembership("invite")); - } - return members; - }; - - /** - * Determine whether we should encrypt messages for invited users in this room - * @return {boolean} if we should encrypt messages for invited users - */ - Room.prototype.shouldEncryptForInvitedMembers = function() { - const ev = this.currentState.getStateEvents("m.room.history_visibility", ""); - return (ev && ev.getContent() && ev.getContent().history_visibility !== "joined"); - }; - - /** - * Get the default room name (i.e. what a given user would see if the - * room had no m.room.name) - * @param {string} userId The userId from whose perspective we want - * to calculate the default name - * @return {string} The default room name - */ - Room.prototype.getDefaultRoomName = function(userId) { - return calculateRoomName(this, userId, true); - }; - - /** - * Check if the given user_id has the given membership state. - * @param {string} userId The user ID to check. - * @param {string} membership The membership e.g. 'join' - * @return {boolean} True if this user_id has the given membership state. - */ - Room.prototype.hasMembershipState = function(userId, membership) { - const member = this.getMember(userId); - if (!member) { - return false; - } - return member.membership === membership; - }; - -/** - * Add a timelineSet for this room with the given filter - * @param {Filter} filter The filter to be applied to this timelineSet - * @return {EventTimelineSet} The timelineSet - */ -Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { - if (this._filteredTimelineSets[filter.filterId]) { - return this._filteredTimelineSets[filter.filterId]; - } - const opts = Object.assign({ filter: filter }, this._opts); - const timelineSet = new EventTimelineSet(this, opts); - this.reEmitter.reEmit(timelineSet, ["Room.timeline", "Room.timelineReset"]); - this._filteredTimelineSets[filter.filterId] = timelineSet; - this._timelineSets.push(timelineSet); - - // populate up the new timelineSet with filtered events from our live - // unfiltered timeline. - // - // XXX: This is risky as our timeline - // may have grown huge and so take a long time to filter. - // see https://github.com/vector-im/vector-web/issues/2109 - - const unfilteredLiveTimeline = this.getLiveTimeline(); - - unfilteredLiveTimeline.getEvents().forEach(function(event) { - timelineSet.addLiveEvent(event); - }); - - // find the earliest unfiltered timeline - let timeline = unfilteredLiveTimeline; - while (timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) { - timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); - } - - timelineSet.getLiveTimeline().setPaginationToken( - timeline.getPaginationToken(EventTimeline.BACKWARDS), - EventTimeline.BACKWARDS, - ); - - // alternatively, we could try to do something like this to try and re-paginate - // in the filtered events from nothing, but Mark says it's an abuse of the API - // to do so: - // - // timelineSet.resetLiveTimeline( - // unfilteredLiveTimeline.getPaginationToken(EventTimeline.FORWARDS) - // ); - - return timelineSet; -}; - -/** - * Forget the timelineSet for this room with the given filter - * - * @param {Filter} filter the filter whose timelineSet is to be forgotten - */ -Room.prototype.removeFilteredTimelineSet = function(filter) { - const timelineSet = this._filteredTimelineSets[filter.filterId]; - delete this._filteredTimelineSets[filter.filterId]; - const i = this._timelineSets.indexOf(timelineSet); - if (i > -1) { - this._timelineSets.splice(i, 1); - } -}; - -/** - * Add an event to the end of this room's live timelines. Will fire - * "Room.timeline". - * - * @param {MatrixEvent} event Event to be added - * @param {string?} duplicateStrategy 'ignore' or 'replace' - * @param {boolean} fromCache whether the sync response came from cache - * @fires module:client~MatrixClient#event:"Room.timeline" - * @private - */ -Room.prototype._addLiveEvent = function(event, duplicateStrategy, fromCache) { - if (event.isRedaction()) { - const redactId = event.event.redacts; - - // if we know about this event, redact its contents now. - const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); - if (redactedEvent) { - redactedEvent.makeRedacted(event); - - // If this is in the current state, replace it with the redacted version - if (redactedEvent.getStateKey()) { - const currentStateEvent = this.currentState.getStateEvents( - redactedEvent.getType(), - redactedEvent.getStateKey(), - ); - if (currentStateEvent.getId() === redactedEvent.getId()) { - this.currentState.setStateEvents([redactedEvent]); - } - } - - this.emit("Room.redaction", event, this); - - // TODO: we stash user displaynames (among other things) in - // RoomMember objects which are then attached to other events - // (in the sender and target fields). We should get those - // RoomMember objects to update themselves when the events that - // they are based on are changed. - } - - // FIXME: apply redactions to notification list - - // NB: We continue to add the redaction event to the timeline so - // clients can say "so and so redacted an event" if they wish to. Also - // this may be needed to trigger an update. - } - - if (event.getUnsigned().transaction_id) { - const existingEvent = this._txnToEvent[event.getUnsigned().transaction_id]; - if (existingEvent) { - // remote echo of an event we sent earlier - this._handleRemoteEcho(event, existingEvent); - return; - } - } - - // add to our timeline sets - for (let i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); - } - - // synthesize and inject implicit read receipts - // Done after adding the event because otherwise the app would get a read receipt - // pointing to an event that wasn't yet in the timeline - // Don't synthesize RR for m.room.redaction as this causes the RR to go missing. - if (event.sender && event.getType() !== "m.room.redaction") { - this.addReceipt(synthesizeReceipt( - event.sender.userId, event, "m.read", - ), true); - - // Any live events from a user could be taken as implicit - // presence information: evidence that they are currently active. - // ...except in a world where we use 'user.currentlyActive' to reduce - // presence spam, this isn't very useful - we'll get a transition when - // they are no longer currently active anyway. So don't bother to - // reset the lastActiveAgo and lastPresenceTs from the RoomState's user. - } -}; - -/** - * Add a pending outgoing event to this room. - * - *

The event is added to either the pendingEventList, or the live timeline, - * depending on the setting of opts.pendingEventOrdering. - * - *

This is an internal method, intended for use by MatrixClient. - * - * @param {module:models/event.MatrixEvent} event The event to add. - * - * @param {string} txnId Transaction id for this outgoing event - * - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" - * - * @throws if the event doesn't have status SENDING, or we aren't given a - * unique transaction id. - */ -Room.prototype.addPendingEvent = function(event, txnId) { - if (event.status !== EventStatus.SENDING && event.status !== EventStatus.NOT_SENT) { - throw new Error("addPendingEvent called on an event with status " + - event.status); - } - - if (this._txnToEvent[txnId]) { - throw new Error("addPendingEvent called on an event with known txnId " + - txnId); - } - - // call setEventMetadata to set up event.sender etc - // as event is shared over all timelineSets, we set up its metadata based - // on the unfiltered timelineSet. - EventTimeline.setEventMetadata( - event, - this.getLiveTimeline().getState(EventTimeline.FORWARDS), - false, - ); - - this._txnToEvent[txnId] = event; - - if (this._opts.pendingEventOrdering == "detached") { - if (this._pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) { - logger.warn("Setting event as NOT_SENT due to messages in the same state"); - event.setStatus(EventStatus.NOT_SENT); - } - this._pendingEventList.push(event); - this._savePendingEvents(); - if (event.isRelation()) { - // For pending events, add them to the relations collection immediately. - // (The alternate case below already covers this as part of adding to - // the timeline set.) - this._aggregateNonLiveRelation(event); - } - - if (event.isRedaction()) { - const redactId = event.event.redacts; - let redactedEvent = this._pendingEventList && - this._pendingEventList.find(e => e.getId() === redactId); - if (!redactedEvent) { - redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); - } - if (redactedEvent) { - redactedEvent.markLocallyRedacted(event); - this.emit("Room.redaction", event, this); - } - } - } else { - for (let i = 0; i < this._timelineSets.length; i++) { - const timelineSet = this._timelineSets[i]; - if (timelineSet.getFilter()) { - if (timelineSet.getFilter().filterRoomTimeline([event]).length) { - timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); - } - } else { - timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); - } - } - } - - this.emit("Room.localEchoUpdated", event, this, null, null); -}; - -/** - * Persists all pending events to local storage - * - * If the current room is encrypted only encrypted events will be persisted - * all messages that are not yet encrypted will be discarded - * - * This is because the flow of EVENT_STATUS transition is - * queued => sending => encrypting => sending => sent - * - * Steps 3 and 4 are skipped for unencrypted room. - * It is better to discard an unencrypted message rather than persisting - * it locally for everyone to read - */ -Room.prototype._savePendingEvents = function() { - if (this._pendingEventList) { - const pendingEvents = this._pendingEventList.map(event => { - return { - ...event.event, - txn_id: event.getTxnId(), - }; - }).filter(event => { - // Filter out the unencrypted messages if the room is encrypted - const isEventEncrypted = event.type === "m.room.encrypted"; - const isRoomEncrypted = this._client.isRoomEncrypted(this.roomId); - return isEventEncrypted || !isRoomEncrypted; - }); - - const { store } = this._client.sessionStore; - if (this._pendingEventList.length > 0) { - store.setItem( - pendingEventsKey(this.roomId), - JSON.stringify(pendingEvents), - ); - } else { - store.removeItem(pendingEventsKey(this.roomId)); - } - } -}; - -/** - * Used to aggregate the local echo for a relation, and also - * for re-applying a relation after it's redaction has been cancelled, - * as the local echo for the redaction of the relation would have - * un-aggregated the relation. Note that this is different from regular messages, - * which are just kept detached for their local echo. - * - * Also note that live events are aggregated in the live EventTimelineSet. - * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. - */ -Room.prototype._aggregateNonLiveRelation = function(event) { - // TODO: We should consider whether this means it would be a better - // design to lift the relations handling up to the room instead. - for (let i = 0; i < this._timelineSets.length; i++) { - const timelineSet = this._timelineSets[i]; - if (timelineSet.getFilter()) { - if (timelineSet.getFilter().filterRoomTimeline([event]).length) { - timelineSet.aggregateRelations(event); - } - } else { - timelineSet.aggregateRelations(event); - } - } -}; - -/** - * Deal with the echo of a message we sent. - * - *

We move the event to the live timeline if it isn't there already, and - * update it. - * - * @param {module:models/event.MatrixEvent} remoteEvent The event received from - * /sync - * @param {module:models/event.MatrixEvent} localEvent The local echo, which - * should be either in the _pendingEventList or the timeline. - * - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" - * @private - */ -Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) { - const oldEventId = localEvent.getId(); - const newEventId = remoteEvent.getId(); - const oldStatus = localEvent.status; - - logger.debug( - `Got remote echo for event ${oldEventId} -> ${newEventId} ` + - `old status ${oldStatus}`, - ); - - // no longer pending - delete this._txnToEvent[remoteEvent.getUnsigned().transaction_id]; - - // if it's in the pending list, remove it - if (this._pendingEventList) { - this.removePendingEvent(oldEventId); - } - - // replace the event source (this will preserve the plaintext payload if - // any, which is good, because we don't want to try decoding it again). - localEvent.handleRemoteEcho(remoteEvent.event); - - for (let i = 0; i < this._timelineSets.length; i++) { - const timelineSet = this._timelineSets[i]; - - // if it's already in the timeline, update the timeline map. If it's not, add it. - timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); - } - - this.emit("Room.localEchoUpdated", localEvent, this, - oldEventId, oldStatus); -}; - -/* a map from current event status to a list of allowed next statuses - */ -const ALLOWED_TRANSITIONS = {}; - -ALLOWED_TRANSITIONS[EventStatus.ENCRYPTING] = [ - EventStatus.SENDING, - EventStatus.NOT_SENT, -]; - -ALLOWED_TRANSITIONS[EventStatus.SENDING] = [ - EventStatus.ENCRYPTING, - EventStatus.QUEUED, - EventStatus.NOT_SENT, - EventStatus.SENT, -]; - -ALLOWED_TRANSITIONS[EventStatus.QUEUED] = - [EventStatus.SENDING, EventStatus.CANCELLED]; - -ALLOWED_TRANSITIONS[EventStatus.SENT] = - []; - -ALLOWED_TRANSITIONS[EventStatus.NOT_SENT] = - [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED]; - -ALLOWED_TRANSITIONS[EventStatus.CANCELLED] = - []; - -/** - * Update the status / event id on a pending event, to reflect its transmission - * progress. - * - *

This is an internal method. - * - * @param {MatrixEvent} event local echo event - * @param {EventStatus} newStatus status to assign - * @param {string} newEventId new event id to assign. Ignored unless - * newStatus == EventStatus.SENT. - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" - */ -Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { - logger.log( - `setting pendingEvent status to ${newStatus} in ${event.getRoomId()} ` + - `event ID ${event.getId()} -> ${newEventId}`, - ); - - // if the message was sent, we expect an event id - if (newStatus == EventStatus.SENT && !newEventId) { - throw new Error("updatePendingEvent called with status=SENT, " + - "but no new event id"); - } - - // SENT races against /sync, so we have to special-case it. - if (newStatus == EventStatus.SENT) { - const timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId); - if (timeline) { - // we've already received the event via the event stream. - // nothing more to do here. - return; - } - } - - const oldStatus = event.status; - const oldEventId = event.getId(); - - if (!oldStatus) { - throw new Error("updatePendingEventStatus called on an event which is " + - "not a local echo."); - } - - const allowed = ALLOWED_TRANSITIONS[oldStatus]; - if (!allowed || allowed.indexOf(newStatus) < 0) { - throw new Error("Invalid EventStatus transition " + oldStatus + "->" + - newStatus); - } - - event.setStatus(newStatus); - - if (newStatus == EventStatus.SENT) { - // update the event id - event.replaceLocalEventId(newEventId); - - // if the event was already in the timeline (which will be the case if - // opts.pendingEventOrdering==chronological), we need to update the - // timeline map. - for (let i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[i].replaceEventId(oldEventId, newEventId); - } - } else if (newStatus == EventStatus.CANCELLED) { - // remove it from the pending event list, or the timeline. - if (this._pendingEventList) { - const idx = this._pendingEventList.findIndex(ev => ev.getId() === oldEventId); - if (idx !== -1) { - const [removedEvent] = this._pendingEventList.splice(idx, 1); - if (removedEvent.isRedaction()) { - this._revertRedactionLocalEcho(removedEvent); - } - } - } - this.removeEvent(oldEventId); - } - this._savePendingEvents(); - - this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus); -}; - -Room.prototype._revertRedactionLocalEcho = function(redactionEvent) { - const redactId = redactionEvent.event.redacts; - if (!redactId) { - return; - } - const redactedEvent = this.getUnfilteredTimelineSet() - .findEventById(redactId); - if (redactedEvent) { - redactedEvent.unmarkLocallyRedacted(); - // re-render after undoing redaction - this.emit("Room.redactionCancelled", redactionEvent, this); - // reapply relation now redaction failed - if (redactedEvent.isRelation()) { - this._aggregateNonLiveRelation(redactedEvent); - } - } -}; - -/** - * Add some events to this room. This can include state events, message - * events and typing notifications. These events are treated as "live" so - * they will go to the end of the timeline. - * - * @param {MatrixEvent[]} events A list of events to add. - * - * @param {string} duplicateStrategy Optional. Applies to events in the - * timeline only. If this is 'replace' then if a duplicate is encountered, the - * event passed to this function will replace the existing event in the - * timeline. If this is not specified, or is 'ignore', then the event passed to - * this function will be ignored entirely, preserving the existing event in the - * timeline. Events are identical based on their event ID only. - * - * @param {boolean} fromCache whether the sync response came from cache - * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. - */ -Room.prototype.addLiveEvents = function(events, duplicateStrategy, fromCache) { - let i; - if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { - throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); - } - - // sanity check that the live timeline is still live - for (i = 0; i < this._timelineSets.length; i++) { - const liveTimeline = this._timelineSets[i].getLiveTimeline(); - if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { - throw new Error( - "live timeline " + i + " is no longer live - it has a pagination token " + - "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")", - ); - } - if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { - throw new Error( - "live timeline " + i + " is no longer live - " + - "it has a neighbouring timeline", - ); - } - } - - for (i = 0; i < events.length; i++) { - // TODO: We should have a filter to say "only add state event - // types X Y Z to the timeline". - this._addLiveEvent(events[i], duplicateStrategy, fromCache); - } -}; - -/** - * Adds/handles ephemeral events such as typing notifications and read receipts. - * @param {MatrixEvent[]} events A list of events to process - */ -Room.prototype.addEphemeralEvents = function(events) { - for (const event of events) { - if (event.getType() === 'm.typing') { - this.currentState.setTypingEvent(event); - } else if (event.getType() === 'm.receipt') { - this.addReceipt(event); - } // else ignore - life is too short for us to care about these events - } -}; - -/** - * Removes events from this room. - * @param {String[]} eventIds A list of eventIds to remove. - */ -Room.prototype.removeEvents = function(eventIds) { - for (let i = 0; i < eventIds.length; ++i) { - this.removeEvent(eventIds[i]); - } -}; - -/** - * Removes a single event from this room. - * - * @param {String} eventId The id of the event to remove - * - * @return {bool} true if the event was removed from any of the room's timeline sets - */ -Room.prototype.removeEvent = function(eventId) { - let removedAny = false; - for (let i = 0; i < this._timelineSets.length; i++) { - const removed = this._timelineSets[i].removeEvent(eventId); - if (removed) { - if (removed.isRedaction()) { - this._revertRedactionLocalEcho(removed); - } - removedAny = true; - } - } - return removedAny; -}; - -/** - * Recalculate various aspects of the room, including the room name and - * room summary. Call this any time the room's current state is modified. - * May fire "Room.name" if the room name is updated. - * @fires module:client~MatrixClient#event:"Room.name" - */ -Room.prototype.recalculate = function() { - // set fake stripped state events if this is an invite room so logic remains - // consistent elsewhere. - const self = this; - const membershipEvent = this.currentState.getStateEvents( - "m.room.member", this.myUserId, - ); - if (membershipEvent && membershipEvent.getContent().membership === "invite") { - const strippedStateEvents = membershipEvent.event.invite_room_state || []; - strippedStateEvents.forEach(function(strippedEvent) { - const existingEvent = self.currentState.getStateEvents( - strippedEvent.type, strippedEvent.state_key, - ); - if (!existingEvent) { - // set the fake stripped event instead - self.currentState.setStateEvents([new MatrixEvent({ - type: strippedEvent.type, - state_key: strippedEvent.state_key, - content: strippedEvent.content, - event_id: "$fake" + Date.now(), - room_id: self.roomId, - user_id: self.myUserId, // technically a lie - })]); - } - }); - } - - const oldName = this.name; - this.name = calculateRoomName(this, this.myUserId); - this.normalizedName = normalize(this.name); - this.summary = new RoomSummary(this.roomId, { - title: this.name, - }); - - if (oldName !== this.name) { - this.emit("Room.name", this); - } -}; - -/** - * Get a list of user IDs who have read up to the given event. - * @param {MatrixEvent} event the event to get read receipts for. - * @return {String[]} A list of user IDs. - */ -Room.prototype.getUsersReadUpTo = function(event) { - return this.getReceiptsForEvent(event).filter(function(receipt) { - return receipt.type === "m.read"; - }).map(function(receipt) { - return receipt.userId; - }); -}; - -/** - * Get the ID of the event that a given user has read up to, or null if we - * have received no read receipts from them. - * @param {String} userId The user ID to get read receipt event ID for - * @param {Boolean} ignoreSynthesized If true, return only receipts that have been - * sent by the server, not implicit ones generated - * by the JS SDK. - * @return {String} ID of the latest event that the given user has read, or null. - */ -Room.prototype.getEventReadUpTo = function(userId, ignoreSynthesized) { - let receipts = this._receipts; - if (ignoreSynthesized) { - receipts = this._realReceipts; - } - - if ( - receipts["m.read"] === undefined || - receipts["m.read"][userId] === undefined - ) { - return null; - } - - return receipts["m.read"][userId].eventId; -}; - -/** - * Determines if the given user has read a particular event ID with the known - * history of the room. This is not a definitive check as it relies only on - * what is available to the room at the time of execution. - * @param {String} userId The user ID to check the read state of. - * @param {String} eventId The event ID to check if the user read. - * @returns {Boolean} True if the user has read the event, false otherwise. - */ -Room.prototype.hasUserReadEvent = function(userId, eventId) { - const readUpToId = this.getEventReadUpTo(userId, false); - if (readUpToId === eventId) return true; - - if (this.timeline.length - && this.timeline[this.timeline.length - 1].getSender() - && this.timeline[this.timeline.length - 1].getSender() === userId) { - // It doesn't matter where the event is in the timeline, the user has read - // it because they've sent the latest event. - return true; - } - - for (let i = this.timeline.length - 1; i >= 0; --i) { - const ev = this.timeline[i]; - - // If we encounter the target event first, the user hasn't read it - // however if we encounter the readUpToId first then the user has read - // it. These rules apply because we're iterating bottom-up. - if (ev.getId() === eventId) return false; - if (ev.getId() === readUpToId) return true; - } - - // We don't know if the user has read it, so assume not. - return false; -}; - -/** - * Get a list of receipts for the given event. - * @param {MatrixEvent} event the event to get receipts for - * @return {Object[]} A list of receipts with a userId, type and data keys or - * an empty list. - */ -Room.prototype.getReceiptsForEvent = function(event) { - return this._receiptCacheByEventId[event.getId()] || []; -}; - -/** - * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Boolean} fake True if this event is implicit - */ -Room.prototype.addReceipt = function(event, fake) { - // event content looks like: - // content: { - // $event_id: { - // $receipt_type: { - // $user_id: { - // ts: $timestamp - // } - // } - // } - // } - if (fake === undefined) { - fake = false; - } - if (!fake) { - this._addReceiptsToStructure(event, this._realReceipts); - // we don't bother caching real receipts by event ID - // as there's nothing that would read it. - } - this._addReceiptsToStructure(event, this._receipts); - this._receiptCacheByEventId = this._buildReceiptCache(this._receipts); - - // send events after we've regenerated the cache, otherwise things that - // listened for the event would read from a stale cache - this.emit("Room.receipt", event, this); -}; - -/** - * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Object} receipts The object to add receipts to - */ -Room.prototype._addReceiptsToStructure = function(event, receipts) { - const self = this; - Object.keys(event.getContent()).forEach(function(eventId) { - Object.keys(event.getContent()[eventId]).forEach(function(receiptType) { - Object.keys(event.getContent()[eventId][receiptType]).forEach( - function(userId) { - const receipt = event.getContent()[eventId][receiptType][userId]; - - if (!receipts[receiptType]) { - receipts[receiptType] = {}; - } - - const existingReceipt = receipts[receiptType][userId]; - - if (!existingReceipt) { - receipts[receiptType][userId] = {}; - } else { - // we only want to add this receipt if we think it is later - // than the one we already have. (This is managed - // server-side, but because we synthesize RRs locally we - // have to do it here too.) - const ordering = self.getUnfilteredTimelineSet().compareEventOrdering( - existingReceipt.eventId, eventId); - if (ordering !== null && ordering >= 0) { - return; - } - } - - receipts[receiptType][userId] = { - eventId: eventId, - data: receipt, - }; - }); - }); - }); -}; - -/** - * Build and return a map of receipts by event ID - * @param {Object} receipts A map of receipts - * @return {Object} Map of receipts by event ID - */ -Room.prototype._buildReceiptCache = function(receipts) { - const receiptCacheByEventId = {}; - Object.keys(receipts).forEach(function(receiptType) { - Object.keys(receipts[receiptType]).forEach(function(userId) { - const receipt = receipts[receiptType][userId]; - if (!receiptCacheByEventId[receipt.eventId]) { - receiptCacheByEventId[receipt.eventId] = []; - } - receiptCacheByEventId[receipt.eventId].push({ - userId: userId, - type: receiptType, - data: receipt.data, - }); - }); - }); - return receiptCacheByEventId; -}; - -/** - * Add a temporary local-echo receipt to the room to reflect in the - * client the fact that we've sent one. - * @param {string} userId The user ID if the receipt sender - * @param {MatrixEvent} e The event that is to be acknowledged - * @param {string} receiptType The type of receipt - */ -Room.prototype._addLocalEchoReceipt = function(userId, e, receiptType) { - this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); -}; - -/** - * Update the room-tag event for the room. The previous one is overwritten. - * @param {MatrixEvent} event the m.tag event - */ -Room.prototype.addTags = function(event) { - // event content looks like: - // content: { - // tags: { - // $tagName: { $metadata: $value }, - // $tagName: { $metadata: $value }, - // } - // } - - // XXX: do we need to deep copy here? - this.tags = event.getContent().tags || {}; - - // XXX: we could do a deep-comparison to see if the tags have really - // changed - but do we want to bother? - this.emit("Room.tags", event, this); -}; - -/** - * Update the account_data events for this room, overwriting events of the same type. - * @param {Array} events an array of account_data events to add - */ -Room.prototype.addAccountData = function(events) { - for (let i = 0; i < events.length; i++) { - const event = events[i]; - if (event.getType() === "m.tag") { - this.addTags(event); - } - const lastEvent = this.accountData[event.getType()]; - this.accountData[event.getType()] = event; - this.emit("Room.accountData", event, this, lastEvent); - } -}; - -/** - * Access account_data event of given event type for this room - * @param {string} type the type of account_data event to be accessed - * @return {?MatrixEvent} the account_data event in question - */ -Room.prototype.getAccountData = function(type) { - return this.accountData[type]; -}; - -/** - * Returns whether the syncing user has permission to send a message in the room - * @return {boolean} true if the user should be permitted to send - * message events into the room. - */ -Room.prototype.maySendMessage = function() { - return this.getMyMembership() === 'join' && - this.currentState.maySendEvent('m.room.message', this.myUserId); -}; - -/** - * Returns whether the given user has permissions to issue an invite for this room. - * @param {string} userId the ID of the Matrix user to check permissions for - * @returns {boolean} true if the user should be permitted to issue invites for this room. - */ -Room.prototype.canInvite = function(userId) { - let canInvite = this.getMyMembership() === "join"; - const powerLevelsEvent = this.currentState.getStateEvents(EventType.RoomPowerLevels, ""); - const powerLevels = powerLevelsEvent && powerLevelsEvent.getContent(); - const me = this.getMember(userId); - if (powerLevels && me && powerLevels.invite > me.powerLevel) { - canInvite = false; - } - return canInvite; -}; - -/** - * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. - * @returns {string} the join_rule applied to this room - */ -Room.prototype.getJoinRule = function() { - return this.currentState.getJoinRule(); -}; - -/** - * Returns the type of the room from the `m.room.create` event content or undefined if none is set - * @returns {?string} the type of the room. Currently only RoomType.Space is known. - */ -Room.prototype.getType = function() { - const createEvent = this.currentState.getStateEvents("m.room.create", ""); - if (!createEvent) { - if (!this.getTypeWarning) { - logger.warn("[getType] Room " + this.roomId + " does not have an m.room.create event"); - this.getTypeWarning = true; - } - return undefined; - } - return createEvent.getContent()[RoomCreateTypeField]; -}; - -/** - * Returns whether the room is a space-room as defined by MSC1772. - * @returns {boolean} true if the room's type is RoomType.Space - */ -Room.prototype.isSpaceRoom = function() { - return this.getType() === RoomType.Space; -}; - -/** - * This is an internal method. Calculates the name of the room from the current - * room state. - * @param {Room} room The matrix room. - * @param {string} userId The client's user ID. Used to filter room members - * correctly. - * @param {bool} ignoreRoomNameEvent Return the implicit room name that we'd see if there - * was no m.room.name event. - * @return {string} The calculated room name. - */ -function calculateRoomName(room, userId, ignoreRoomNameEvent) { - if (!ignoreRoomNameEvent) { - // check for an alias, if any. for now, assume first alias is the - // official one. - const mRoomName = room.currentState.getStateEvents("m.room.name", ""); - if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) { - return mRoomName.getContent().name; - } - } - - let alias = room.getCanonicalAlias(); - - if (!alias) { - const aliases = room.getAltAliases(); - - if (aliases.length) { - alias = aliases[0]; - } - } - if (alias) { - return alias; - } - - const joinedMemberCount = room.currentState.getJoinedMemberCount(); - const invitedMemberCount = room.currentState.getInvitedMemberCount(); - // -1 because these numbers include the syncing user - const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; - - // get members that are NOT ourselves and are actually in the room. - let otherNames = null; - if (room._summaryHeroes) { - // if we have a summary, the member state events - // should be in the room state - otherNames = room._summaryHeroes.map((userId) => { - const member = room.getMember(userId); - return member ? member.name : userId; - }); - } else { - let otherMembers = room.currentState.getMembers().filter((m) => { - return m.userId !== userId && - (m.membership === "invite" || m.membership === "join"); - }); - // make sure members have stable order - otherMembers.sort((a, b) => a.userId.localeCompare(b.userId)); - // only 5 first members, immitate _summaryHeroes - otherMembers = otherMembers.slice(0, 5); - otherNames = otherMembers.map((m) => m.name); - } - - if (inviteJoinCount) { - return memberNamesToRoomName(otherNames, inviteJoinCount); - } - - const myMembership = room.getMyMembership(); - // if I have created a room and invited people throuh - // 3rd party invites - if (myMembership == 'join') { - const thirdPartyInvites = - room.currentState.getStateEvents("m.room.third_party_invite"); - - if (thirdPartyInvites && thirdPartyInvites.length) { - const thirdPartyNames = thirdPartyInvites.map((i) => { - return i.getContent().display_name; - }); - - return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`; - } - } - // let's try to figure out who was here before - let leftNames = otherNames; - // if we didn't have heroes, try finding them in the room state - if (!leftNames.length) { - leftNames = room.currentState.getMembers().filter((m) => { - return m.userId !== userId && - m.membership !== "invite" && - m.membership !== "join"; - }).map((m) => m.name); - } - if (leftNames.length) { - return `Empty room (was ${memberNamesToRoomName(leftNames)})`; - } else { - return "Empty room"; - } -} - -function memberNamesToRoomName(names, count = (names.length + 1)) { - const countWithoutMe = count - 1; - if (!names.length) { - return "Empty room"; - } else if (names.length === 1 && countWithoutMe <= 1) { - return names[0]; - } else if (names.length === 2 && countWithoutMe <= 2) { - return `${names[0]} and ${names[1]}`; - } else { - const plural = countWithoutMe > 1; - if (plural) { - return `${names[0]} and ${countWithoutMe} others`; - } else { - return `${names[0]} and 1 other`; - } - } -} - -/** - * Fires when an event we had previously received is redacted. - * - * (Note this is *not* fired when the redaction happens before we receive the - * event). - * - * @event module:client~MatrixClient#"Room.redaction" - * @param {MatrixEvent} event The matrix redaction event - * @param {Room} room The room containing the redacted event - */ - -/** - * Fires when an event that was previously redacted isn't anymore. - * This happens when the redaction couldn't be sent and - * was subsequently cancelled by the user. Redactions have a local echo - * which is undone in this scenario. - * - * @event module:client~MatrixClient#"Room.redactionCancelled" - * @param {MatrixEvent} event The matrix redaction event that was cancelled. - * @param {Room} room The room containing the unredacted event - */ - -/** - * Fires whenever the name of a room is updated. - * @event module:client~MatrixClient#"Room.name" - * @param {Room} room The room whose Room.name was updated. - * @example - * matrixClient.on("Room.name", function(room){ - * var newName = room.name; - * }); - */ - -/** - * Fires whenever a receipt is received for a room - * @event module:client~MatrixClient#"Room.receipt" - * @param {event} event The receipt event - * @param {Room} room The room whose receipts was updated. - * @example - * matrixClient.on("Room.receipt", function(event, room){ - * var receiptContent = event.getContent(); - * }); - */ - -/** - * Fires whenever a room's tags are updated. - * @event module:client~MatrixClient#"Room.tags" - * @param {event} event The tags event - * @param {Room} room The room whose Room.tags was updated. - * @example - * matrixClient.on("Room.tags", function(event, room){ - * var newTags = event.getContent().tags; - * if (newTags["favourite"]) showStar(room); - * }); - */ - -/** - * Fires whenever a room's account_data is updated. - * @event module:client~MatrixClient#"Room.accountData" - * @param {event} event The account_data event - * @param {Room} room The room whose account_data was updated. - * @param {MatrixEvent} prevEvent The event being replaced by - * the new account data, if known. - * @example - * matrixClient.on("Room.accountData", function(event, room, oldEvent){ - * if (event.getType() === "m.room.colorscheme") { - * applyColorScheme(event.getContents()); - * } - * }); - */ - -/** - * Fires when the status of a transmitted event is updated. - * - *

When an event is first transmitted, a temporary copy of the event is - * inserted into the timeline, with a temporary event id, and a status of - * 'SENDING'. - * - *

Once the echo comes back from the server, the content of the event - * (MatrixEvent.event) is replaced by the complete event from the homeserver, - * thus updating its event id, as well as server-generated fields such as the - * timestamp. Its status is set to null. - * - *

Once the /send request completes, if the remote echo has not already - * arrived, the event is updated with a new event id and the status is set to - * 'SENT'. The server-generated fields are of course not updated yet. - * - *

If the /send fails, In this case, the event's status is set to - * 'NOT_SENT'. If it is later resent, the process starts again, setting the - * status to 'SENDING'. Alternatively, the message may be cancelled, which - * removes the event from the room, and sets the status to 'CANCELLED'. - * - *

This event is raised to reflect each of the transitions above. - * - * @event module:client~MatrixClient#"Room.localEchoUpdated" - * - * @param {MatrixEvent} event The matrix event which has been updated - * - * @param {Room} room The room containing the redacted event - * - * @param {string} oldEventId The previous event id (the temporary event id, - * except when updating a successfully-sent event when its echo arrives) - * - * @param {EventStatus} oldStatus The previous event status. - */ - -/** - * Fires when the logged in user's membership in the room is updated. - * - * @event module:models/room~Room#"Room.myMembership" - * @param {Room} room The room in which the membership has been updated - * @param {string} membership The new membership value - * @param {string} prevMembership The previous membership value - */ diff --git a/src/models/room.ts b/src/models/room.ts new file mode 100644 index 000000000..787b409d6 --- /dev/null +++ b/src/models/room.ts @@ -0,0 +1,2272 @@ +/* +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. +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 models/room + */ + +import { EventEmitter } from "events"; + +import { EventTimelineSet } from "./event-timeline-set"; +import { EventTimeline } from "./event-timeline"; +import { getHttpUriForMxc } from "../content-repo"; +import * as utils from "../utils"; +import { normalize } from "../utils"; +import { EventStatus, MatrixEvent } from "./event"; +import { RoomMember } from "./room-member"; +import { IRoomSummary, RoomSummary } from "./room-summary"; +import { logger } from '../logger'; +import { ReEmitter } from '../ReEmitter'; +import { EventType, RoomCreateTypeField, RoomType } from "../@types/event"; +import { IRoomVersionsCapability, MatrixClient, RoomVersionStability } from "../client"; +import { ResizeMethod } from "../@types/partials"; +import { Filter } from "../filter"; +import { RoomState } from "./room-state"; + +// These constants are used as sane defaults when the homeserver doesn't support +// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be +// the same as the common default room version whereas SAFE_ROOM_VERSIONS are the +// room versions which are considered okay for people to run without being asked +// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers +// return an m.room_versions capability. +const KNOWN_SAFE_ROOM_VERSION = '6'; +const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6']; + +function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: string): MatrixEvent { + // console.log("synthesizing receipt for "+event.getId()); + // This is really ugly because JS has no way to express an object literal + // where the name of a key comes from an expression + const fakeReceipt = { + content: {}, + type: "m.receipt", + room_id: event.getRoomId(), + }; + fakeReceipt.content[event.getId()] = {}; + fakeReceipt.content[event.getId()][receiptType] = {}; + fakeReceipt.content[event.getId()][receiptType][userId] = { + ts: event.getTs(), + }; + return new MatrixEvent(fakeReceipt); +} + +interface IOpts { + storageToken?: string; + pendingEventOrdering?: "chronological" | "detached"; + timelineSupport?: boolean; + unstableClientRelationAggregation?: boolean; + lazyLoadMembers?: boolean; +} + +export interface IRecommendedVersion { + version: string; + needsUpgrade: boolean; + urgent: boolean; +} + +interface IReceipt { + ts: number; +} + +interface IWrappedReceipt { + eventId: string; + data: IReceipt; +} + +interface ICachedReceipt { + type: string; + userId: string; + data: IReceipt; +} + +type ReceiptCache = Record; + +interface IReceiptContent { + [eventId: string]: { + [type: string]: { + [userId: string]: IReceipt; + }; + }; +} + +type Receipts = Record>; + +export enum NotificationCountType { + Highlight = "highlight", + Total = "total", +} + +export class Room extends EventEmitter { + private readonly reEmitter: ReEmitter; + private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } + // receipts should clobber based on receipt_type and user_id pairs hence + // the form of this structure. This is sub-optimal for the exposed APIs + // which pass in an event ID and get back some receipts, so we also store + // a pre-cached list for this purpose. + private receipts: Receipts = {}; // { receipt_type: { user_id: IReceipt } } + private receiptCacheByEventId: ReceiptCache = {}; // { event_id: IReceipt2[] } + // only receipts that came from the server, not synthesized ones + private realReceipts: Receipts = {}; + private notificationCounts: Partial> = {}; + private readonly timelineSets: EventTimelineSet[]; + // any filtered timeline sets we're maintaining for this room + private readonly filteredTimelineSets: Record = {}; // filter_id: timelineSet + private readonly pendingEventList?: MatrixEvent[]; + // read by megolm via getter; boolean value - null indicates "use global value" + private blacklistUnverifiedDevices: boolean = null; + private selfMembership: string = null; + private summaryHeroes: string[] = null; + // flags to stop logspam about missing m.room.create events + private getTypeWarning = false; + private getVersionWarning = false; + private membersPromise?: Promise; + + // XXX: These should be read-only + public name: string; + public normalizedName: string; + public tags: Record> = {}; // $tagName: { $metadata: $value } + public accountData: Record = {}; // $eventType: $event + public summary: RoomSummary = null; + public readonly storageToken?: string; + // legacy fields + public timeline: MatrixEvent[]; + public oldState: RoomState; + public currentState: RoomState; + + /** + * Construct a new Room. + * + *

For a room, we store an ordered sequence of timelines, which may or may not + * be continuous. Each timeline lists a series of events, as well as tracking + * the room state at the start and the end of the timeline. It also tracks + * forward and backward pagination tokens, as well as containing links to the + * next timeline in the sequence. + * + *

There is one special timeline - the 'live' timeline, which represents the + * timeline to which events are being added in real-time as they are received + * from the /sync API. Note that you should not retain references to this + * timeline - even if it is the current timeline right now, it may not remain + * so if the server gives us a timeline gap in /sync. + * + *

In order that we can find events from their ids later, we also maintain a + * map from event_id to timeline and index. + * + * @constructor + * @alias module:models/room + * @param {string} roomId Required. The ID of this room. + * @param {MatrixClient} client Required. The client, used to lazy load members. + * @param {string} myUserId Required. The ID of the syncing user. + * @param {Object=} opts Configuration options + * @param {*} opts.storageToken Optional. The token which a data store can use + * to remember the state of the room. What this means is dependent on the store + * implementation. + * + * @param {String=} opts.pendingEventOrdering Controls where pending messages + * appear in a room's timeline. If "chronological", messages will appear + * in the timeline when the call to sendEvent was made. If + * "detached", pending messages will appear in a separate list, + * accessible via {@link module:models/room#getPendingEvents}. Default: + * "chronological". + * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved + * timeline support. + * @param {boolean} [opts.unstableClientRelationAggregation = false] + * Optional. Set to true to enable client-side aggregation of event relations + * via `EventTimelineSet#getRelationsForEvent`. + * This feature is currently unstable and the API may change without notice. + * + * @prop {string} roomId The ID of this room. + * @prop {string} name The human-readable display name for this room. + * @prop {string} normalizedName The un-homoglyphed name for this room. + * @prop {Array} timeline The live event timeline for this room, + * with the oldest event at index 0. Present for backwards compatibility - + * prefer getLiveTimeline().getEvents(). + * @prop {object} tags Dict of room tags; the keys are the tag name and the values + * are any metadata associated with the tag - e.g. { "fav" : { order: 1 } } + * @prop {object} accountData Dict of per-room account_data events; the keys are the + * event type and the values are the events. + * @prop {RoomState} oldState The state of the room at the time of the oldest + * event in the live timeline. Present for backwards compatibility - + * prefer getLiveTimeline().getState(EventTimeline.BACKWARDS). + * @prop {RoomState} currentState The state of the room at the time of the + * newest event in the timeline. Present for backwards compatibility - + * prefer getLiveTimeline().getState(EventTimeline.FORWARDS). + * @prop {RoomSummary} summary The room summary. + * @prop {*} storageToken A token which a data store can use to remember + * the state of the room. + */ + constructor( + public readonly roomId: string, + private readonly client: MatrixClient, + public readonly myUserId: string, + private readonly opts: IOpts = {}, + ) { + super(); + // In some cases, we add listeners for every displayed Matrix event, so it's + // common to have quite a few more than the default limit. + this.setMaxListeners(100); + this.reEmitter = new ReEmitter(this); + + opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; + if (["chronological", "detached"].indexOf(opts.pendingEventOrdering) === -1) { + throw new Error( + "opts.pendingEventOrdering MUST be either 'chronological' or " + + "'detached'. Got: '" + opts.pendingEventOrdering + "'", + ); + } + + this.name = roomId; + this.normalizedName = normalize(this.name); + + // all our per-room timeline sets. the first one is the unfiltered ones; + // the subsequent ones are the filtered ones in no particular order. + this.timelineSets = [new EventTimelineSet(this, opts)]; + this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), ["Room.timeline", "Room.timelineReset"]); + + this.fixUpLegacyTimelineFields(); + + if (this.opts.pendingEventOrdering == "detached") { + this.pendingEventList = []; + 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() === EventType.RoomMessageEncrypted) { + await event.attemptDecryption(this.client.crypto); + } + event.setStatus(EventStatus.NOT_SENT); + this.addPendingEvent(event, event.getTxnId()); + }); + } + } + + // awaited by getEncryptionTargetMembers while room members are loading + if (!this.opts.lazyLoadMembers) { + this.membersPromise = Promise.resolve(false); + } else { + this.membersPromise = null; + } + } + + /** + * Bulk decrypt critical events in a room + * + * Critical events represents the minimal set of events to decrypt + * for a typical UI to function properly + * + * - Last event of every room (to generate likely message preview) + * - All events up to the read receipt (to calculate an accurate notification count) + * + * @returns {Promise} Signals when all events have been decrypted + */ + public decryptCriticalEvents(): Promise { + const readReceiptEventId = this.getEventReadUpTo(this.client.getUserId(), true); + const events = this.getLiveTimeline().getEvents(); + const readReceiptTimelineIndex = events.findIndex(matrixEvent => { + return matrixEvent.event.event_id === readReceiptEventId; + }); + + const decryptionPromises = events + .slice(readReceiptTimelineIndex) + .filter(event => event.shouldAttemptDecryption()) + .reverse() + .map(event => event.attemptDecryption(this.client.crypto, { isRetry: true })); + + return Promise.allSettled(decryptionPromises); + } + + /** + * Bulk decrypt events in a room + * + * @returns {Promise} Signals when all events have been decrypted + */ + public decryptAllEvents(): Promise { + const decryptionPromises = this + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter(event => event.shouldAttemptDecryption()) + .reverse() + .map(event => event.attemptDecryption(this.client.crypto, { isRetry: true })); + + return Promise.allSettled(decryptionPromises); + } + + /** + * Gets the version of the room + * @returns {string} The version of the room, or null if it could not be determined + */ + public getVersion(): string | null { + const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); + if (!createEvent) { + if (!this.getVersionWarning) { + logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event"); + this.getVersionWarning = true; + } + return '1'; + } + const ver = createEvent.getContent()['room_version']; + if (ver === undefined) return '1'; + return ver; + } + + /** + * Determines whether this room needs to be upgraded to a new version + * @returns {string?} What version the room should be upgraded to, or null if + * the room does not require upgrading at this time. + * @deprecated Use #getRecommendedVersion() instead + */ + public shouldUpgradeToVersion(): string | null { + // TODO: Remove this function. + // This makes assumptions about which versions are safe, and can easily + // be wrong. Instead, people are encouraged to use getRecommendedVersion + // which determines a safer value. This function doesn't use that function + // because this is not async-capable, and to avoid breaking the contract + // we're deprecating this. + + if (!SAFE_ROOM_VERSIONS.includes(this.getVersion())) { + return KNOWN_SAFE_ROOM_VERSION; + } + + return null; + } + + /** + * Determines the recommended room version for the room. This returns an + * object with 3 properties: version as the new version the + * room should be upgraded to (may be the same as the current version); + * needsUpgrade to indicate if the room actually can be + * upgraded (ie: does the current version not match?); and urgent + * to indicate if the new version patches a vulnerability in a previous + * version. + * @returns {Promise<{version: string, needsUpgrade: boolean, urgent: boolean}>} + * Resolves to the version the room should be upgraded to. + */ + public async getRecommendedVersion(): Promise { + const capabilities = await this.client.getCapabilities(); + let versionCap = capabilities["m.room_versions"]; + if (!versionCap) { + versionCap = { + default: KNOWN_SAFE_ROOM_VERSION, + available: {}, + }; + for (const safeVer of SAFE_ROOM_VERSIONS) { + versionCap.available[safeVer] = RoomVersionStability.Stable; + } + } + + let result = this.checkVersionAgainstCapability(versionCap); + if (result.urgent && result.needsUpgrade) { + // Something doesn't feel right: we shouldn't need to update + // because the version we're on should be in the protocol's + // namespace. This usually means that the server was updated + // before the client was, making us think the newest possible + // room version is not stable. As a solution, we'll refresh + // the capability we're using to determine this. + logger.warn( + "Refreshing room version capability because the server looks " + + "to be supporting a newer room version we don't know about.", + ); + + const caps = await this.client.getCapabilities(true); + versionCap = caps["m.room_versions"]; + if (!versionCap) { + logger.warn("No room version capability - assuming upgrade required."); + return result; + } else { + result = this.checkVersionAgainstCapability(versionCap); + } + } + + return result; + } + + private checkVersionAgainstCapability(versionCap: IRoomVersionsCapability): IRecommendedVersion { + const currentVersion = this.getVersion(); + logger.log(`[${this.roomId}] Current version: ${currentVersion}`); + logger.log(`[${this.roomId}] Version capability: `, versionCap); + + const result = { + version: currentVersion, + needsUpgrade: false, + urgent: false, + }; + + // If the room is on the default version then nothing needs to change + if (currentVersion === versionCap.default) return result; + + const stableVersions = Object.keys(versionCap.available) + .filter((v) => versionCap.available[v] === 'stable'); + + // Check if the room is on an unstable version. We determine urgency based + // off the version being in the Matrix spec namespace or not (if the version + // is in the current namespace and unstable, the room is probably vulnerable). + if (!stableVersions.includes(currentVersion)) { + result.version = versionCap.default; + result.needsUpgrade = true; + result.urgent = !!this.getVersion().match(/^[0-9]+[0-9.]*$/g); + if (result.urgent) { + logger.warn(`URGENT upgrade required on ${this.roomId}`); + } else { + logger.warn(`Non-urgent upgrade required on ${this.roomId}`); + } + return result; + } + + // The room is on a stable, but non-default, version by this point. + // No upgrade needed. + return result; + } + + /** + * Determines whether the given user is permitted to perform a room upgrade + * @param {String} userId The ID of the user to test against + * @returns {boolean} True if the given user is permitted to upgrade the room + */ + public userMayUpgradeRoom(userId: string): boolean { + return this.currentState.maySendStateEvent(EventType.RoomTombstone, userId); + } + + /** + * Get the list of pending sent events for this room + * + * @return {module:models/event.MatrixEvent[]} A list of the sent events + * waiting for remote echo. + * + * @throws If opts.pendingEventOrdering was not 'detached' + */ + public getPendingEvents(): MatrixEvent[] { + if (this.opts.pendingEventOrdering !== "detached") { + throw new Error( + "Cannot call getPendingEvents with pendingEventOrdering == " + + this.opts.pendingEventOrdering); + } + + return this.pendingEventList; + } + + /** + * Removes a pending event for this room + * + * @param {string} eventId + * @return {boolean} True if an element was removed. + */ + public removePendingEvent(eventId: string): boolean { + if (this.opts.pendingEventOrdering !== "detached") { + throw new Error( + "Cannot call removePendingEvent with pendingEventOrdering == " + + this.opts.pendingEventOrdering); + } + + const removed = utils.removeElement( + this.pendingEventList, + function(ev) { + return ev.getId() == eventId; + }, false, + ); + + this.savePendingEvents(); + + return removed; + } + + /** + * Check whether the pending event list contains a given event by ID. + * If pending event ordering is not "detached" then this returns false. + * + * @param {string} eventId The event ID to check for. + * @return {boolean} + */ + public hasPendingEvent(eventId: string): boolean { + if (this.opts.pendingEventOrdering !== "detached") { + return false; + } + + return this.pendingEventList.some(event => event.getId() === eventId); + } + + /** + * Get a specific event from the pending event list, if configured, null otherwise. + * + * @param {string} eventId The event ID to check for. + * @return {MatrixEvent} + */ + public getPendingEvent(eventId: string): MatrixEvent | null { + if (this.opts.pendingEventOrdering !== "detached") { + return null; + } + + return this.pendingEventList.find(event => event.getId() === eventId); + } + + /** + * Get the live unfiltered timeline for this room. + * + * @return {module:models/event-timeline~EventTimeline} live timeline + */ + public getLiveTimeline(): EventTimeline { + return this.getUnfilteredTimelineSet().getLiveTimeline(); + } + + /** + * Get the timestamp of the last message in the room + * + * @return {number} the timestamp of the last message in the room + */ + public getLastActiveTimestamp(): number { + const timeline = this.getLiveTimeline(); + const events = timeline.getEvents(); + if (events.length) { + const lastEvent = events[events.length - 1]; + return lastEvent.getTs(); + } else { + return Number.MIN_SAFE_INTEGER; + } + } + + /** + * @return {string} the membership type (join | leave | invite) for the logged in user + */ + public getMyMembership(): string { + return this.selfMembership; + } + + /** + * If this room is a DM we're invited to, + * try to find out who invited us + * @return {string} user id of the inviter + */ + public getDMInviter(): string { + if (this.myUserId) { + const me = this.getMember(this.myUserId); + if (me) { + return me.getDMInviter(); + } + } + if (this.selfMembership === "invite") { + // fall back to summary information + const memberCount = this.getInvitedAndJoinedMemberCount(); + if (memberCount == 2 && this.summaryHeroes.length) { + return this.summaryHeroes[0]; + } + } + } + + /** + * Assuming this room is a DM room, tries to guess with which user. + * @return {string} user id of the other member (could be syncing user) + */ + public guessDMUserId(): string { + const me = this.getMember(this.myUserId); + if (me) { + const inviterId = me.getDMInviter(); + if (inviterId) { + return inviterId; + } + } + // remember, we're assuming this room is a DM, + // so returning the first member we find should be fine + const hasHeroes = Array.isArray(this.summaryHeroes) && + this.summaryHeroes.length; + if (hasHeroes) { + return this.summaryHeroes[0]; + } + const members = this.currentState.getMembers(); + const anyMember = members.find((m) => m.userId !== this.myUserId); + if (anyMember) { + return anyMember.userId; + } + // it really seems like I'm the only user in the room + // so I probably created a room with just me in it + // and marked it as a DM. Ok then + return this.myUserId; + } + + public getAvatarFallbackMember(): RoomMember { + const memberCount = this.getInvitedAndJoinedMemberCount(); + if (memberCount > 2) { + return; + } + const hasHeroes = Array.isArray(this.summaryHeroes) && + this.summaryHeroes.length; + if (hasHeroes) { + const availableMember = this.summaryHeroes.map((userId) => { + return this.getMember(userId); + }).find((member) => !!member); + if (availableMember) { + return availableMember; + } + } + const members = this.currentState.getMembers(); + // could be different than memberCount + // as this includes left members + if (members.length <= 2) { + const availableMember = members.find((m) => { + return m.userId !== this.myUserId; + }); + if (availableMember) { + return availableMember; + } + } + // if all else fails, try falling back to a user, + // and create a one-off member for it + if (hasHeroes) { + const availableUser = this.summaryHeroes.map((userId) => { + return this.client.getUser(userId); + }).find((user) => !!user); + if (availableUser) { + const member = new RoomMember( + this.roomId, availableUser.userId); + member.user = availableUser; + return member; + } + } + } + + /** + * Sets the membership this room was received as during sync + * @param {string} membership join | leave | invite + */ + public updateMyMembership(membership: string): void { + const prevMembership = this.selfMembership; + this.selfMembership = membership; + if (prevMembership !== membership) { + if (membership === "leave") { + this.cleanupAfterLeaving(); + } + this.emit("Room.myMembership", this, membership, prevMembership); + } + } + + private async loadMembersFromServer(): Promise { + const lastSyncToken = this.client.store.getSyncToken(); + const queryString = utils.encodeParams({ + not_membership: "leave", + at: lastSyncToken, + }); + const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, + { $roomId: this.roomId }); + const http = this.client.http; + const response = await http.authedRequest(undefined, "GET", path); + return response.chunk; + } + + private async loadMembers(): Promise<{ memberEvents: MatrixEvent[], fromServer: boolean }> { + // were the members loaded from the server? + let fromServer = false; + let rawMembersEvents = + await this.client.store.getOutOfBandMembers(this.roomId); + if (rawMembersEvents === null) { + fromServer = true; + rawMembersEvents = await this.loadMembersFromServer(); + logger.log(`LL: got ${rawMembersEvents.length} ` + + `members from server for room ${this.roomId}`); + } + const memberEvents = rawMembersEvents.map(this.client.getEventMapper()); + return { memberEvents, fromServer }; + } + + /** + * Preloads the member list in case lazy loading + * of memberships is in use. Can be called multiple times, + * it will only preload once. + * @return {Promise} when preloading is done and + * accessing the members on the room will take + * all members in the room into account + */ + public loadMembersIfNeeded(): Promise { + if (this.membersPromise) { + return this.membersPromise; + } + + // mark the state so that incoming messages while + // the request is in flight get marked as superseding + // the OOB members + this.currentState.markOutOfBandMembersStarted(); + + const inMemoryUpdate = this.loadMembers().then((result) => { + 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); + } + return result.fromServer; + }).catch((err) => { + // allow retries on fail + this.membersPromise = null; + this.currentState.markOutOfBandMembersFailed(); + throw err; + }); + // update members in storage, but don't wait for it + inMemoryUpdate.then((fromServer) => { + if (fromServer) { + const oobMembers = this.currentState.getMembers() + .filter((m) => m.isOutOfBand()) + .map((m) => m.events.member.event); + logger.log(`LL: telling store to write ${oobMembers.length}` + + ` members for room ${this.roomId}`); + const store = this.client.store; + return store.setOutOfBandMembers(this.roomId, oobMembers) + // swallow any IDB error as we don't want to fail + // because of this + .catch((err) => { + logger.log("LL: storing OOB room members failed, oh well", + err); + }); + } + }).catch((err) => { + // as this is not awaited anywhere, + // at least show the error in the console + logger.error(err); + }); + + this.membersPromise = inMemoryUpdate; + + return this.membersPromise; + } + + /** + * Removes the lazily loaded members from storage if needed + */ + public async clearLoadedMembersIfNeeded(): Promise { + if (this.opts.lazyLoadMembers && this.membersPromise) { + await this.loadMembersIfNeeded(); + await this.client.store.clearOutOfBandMembers(this.roomId); + this.currentState.clearOutOfBandMembers(); + this.membersPromise = null; + } + } + + /** + * called when sync receives this room in the leave section + * to do cleanup after leaving a room. Possibly called multiple times. + */ + private cleanupAfterLeaving(): void { + this.clearLoadedMembersIfNeeded().catch((err) => { + logger.error(`error after clearing loaded members from ` + + `room ${this.roomId} after leaving`); + logger.log(err); + }); + } + + /** + * Reset the live timeline of all timelineSets, and start new ones. + * + *

This is used when /sync returns a 'limited' timeline. + * + * @param {string=} backPaginationToken token for back-paginating the new timeline + * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, + * if absent or null, all timelines are reset, removing old ones (including the previous live + * timeline which would otherwise be unable to paginate forwards without this token). + * Removing just the old live timeline whilst preserving previous ones is not supported. + */ + public resetLiveTimeline(backPaginationToken: string, forwardPaginationToken: string): void { + for (let i = 0; i < this.timelineSets.length; i++) { + this.timelineSets[i].resetLiveTimeline( + backPaginationToken, forwardPaginationToken, + ); + } + + this.fixUpLegacyTimelineFields(); + } + + /** + * Fix up this.timeline, this.oldState and this.currentState + * + * @private + */ + private fixUpLegacyTimelineFields(): void { + // maintain this.timeline as a reference to the live timeline, + // and this.oldState and this.currentState as references to the + // state at the start and end of that timeline. These are more + // for backwards-compatibility than anything else. + this.timeline = this.getLiveTimeline().getEvents(); + this.oldState = this.getLiveTimeline() + .getState(EventTimeline.BACKWARDS); + this.currentState = this.getLiveTimeline() + .getState(EventTimeline.FORWARDS); + } + + /** + * Returns whether there are any devices in the room that are unverified + * + * Note: Callers should first check if crypto is enabled on this device. If it is + * disabled, then we aren't tracking room devices at all, so we can't answer this, and an + * error will be thrown. + * + * @return {boolean} the result + */ + public async hasUnverifiedDevices(): Promise { + if (!this.client.isRoomEncrypted(this.roomId)) { + return false; + } + const e2eMembers = await this.getEncryptionTargetMembers(); + for (const member of e2eMembers) { + const devices = this.client.getStoredDevicesForUser(member.userId); + if (devices.some((device) => device.isUnverified())) { + return true; + } + } + return false; + } + + /** + * Return the timeline sets for this room. + * @return {EventTimelineSet[]} array of timeline sets for this room + */ + public getTimelineSets(): EventTimelineSet[] { + return this.timelineSets; + } + + /** + * Helper to return the main unfiltered timeline set for this room + * @return {EventTimelineSet} room's unfiltered timeline set + */ + public getUnfilteredTimelineSet(): EventTimelineSet { + return this.timelineSets[0]; + } + + /** + * Get the timeline which contains the given event from the unfiltered set, if any + * + * @param {string} eventId event ID to look for + * @return {?module:models/event-timeline~EventTimeline} timeline containing + * the given event, or null if unknown + */ + public getTimelineForEvent(eventId: string): EventTimeline { + return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId); + } + + /** + * Add a new timeline to this room's unfiltered timeline set + * + * @return {module:models/event-timeline~EventTimeline} newly-created timeline + */ + public addTimeline(): EventTimeline { + return this.getUnfilteredTimelineSet().addTimeline(); + } + + /** + * Get an event which is stored in our unfiltered timeline set + * + * @param {string} eventId event ID to look for + * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown + */ + public findEventById(eventId: string): MatrixEvent | undefined { + return this.getUnfilteredTimelineSet().findEventById(eventId); + } + + /** + * Get one of the notification counts for this room + * @param {String} type The type of notification count to get. default: 'total' + * @return {Number} The notification count, or undefined if there is no count + * for this type. + */ + public getUnreadNotificationCount(type = NotificationCountType.Total): number | undefined { + return this.notificationCounts[type]; + } + + /** + * Set one of the notification counts for this room + * @param {String} type The type of notification count to set. + * @param {Number} count The new count + */ + public setUnreadNotificationCount(type: NotificationCountType, count: number): void { + this.notificationCounts[type] = count; + } + + public setSummary(summary: IRoomSummary): void { + const heroes = summary["m.heroes"]; + const joinedCount = summary["m.joined_member_count"]; + const invitedCount = summary["m.invited_member_count"]; + if (Number.isInteger(joinedCount)) { + this.currentState.setJoinedMemberCount(joinedCount); + } + if (Number.isInteger(invitedCount)) { + this.currentState.setInvitedMemberCount(invitedCount); + } + if (Array.isArray(heroes)) { + // be cautious about trusting server values, + // and make sure heroes doesn't contain our own id + // just to be sure + this.summaryHeroes = heroes.filter((userId) => { + return userId !== this.myUserId; + }); + } + } + + /** + * Whether to send encrypted messages to devices within this room. + * @param {Boolean} value true to blacklist unverified devices, null + * to use the global value for this room. + */ + public setBlacklistUnverifiedDevices(value: boolean): void { + this.blacklistUnverifiedDevices = value; + } + + /** + * Whether to send encrypted messages to devices within this room. + * @return {Boolean} true if blacklisting unverified devices, null + * if the global value should be used for this room. + */ + public getBlacklistUnverifiedDevices(): boolean { + return this.blacklistUnverifiedDevices; + } + + /** + * Get the avatar URL for a room if one was set. + * @param {String} baseUrl The homeserver base URL. See + * {@link module:client~MatrixClient#getHomeserverUrl}. + * @param {Number} width The desired width of the thumbnail. + * @param {Number} height The desired height of the thumbnail. + * @param {string} resizeMethod The thumbnail resize method to use, either + * "crop" or "scale". + * @param {boolean} allowDefault True to allow an identicon for this room if an + * avatar URL wasn't explicitly set. Default: true. (Deprecated) + * @return {?string} the avatar URL or null. + */ + public getAvatarUrl( + baseUrl: string, + width: number, + height: number, + resizeMethod: ResizeMethod, + allowDefault = true, + ): string | null { + const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, ""); + if (!roomAvatarEvent && !allowDefault) { + return null; + } + + const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null; + if (mainUrl) { + return getHttpUriForMxc(baseUrl, mainUrl, width, height, resizeMethod); + } + + return null; + } + + /** + * Get the mxc avatar url for the room, if one was set. + * @return {string} the mxc avatar url or falsy + */ + public getMxcAvatarUrl(): string | null { + return this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url || null; + } + + /** + * Get the aliases this room has according to the room's state + * The aliases returned by this function may not necessarily + * still point to this room. + * @return {array} The room's alias as an array of strings + */ + public getAliases(): string[] { + const aliasStrings = []; + + const aliasEvents = this.currentState.getStateEvents(EventType.RoomAliases); + if (aliasEvents) { + for (let i = 0; i < aliasEvents.length; ++i) { + const aliasEvent = aliasEvents[i]; + if (Array.isArray(aliasEvent.getContent().aliases)) { + const filteredAliases = aliasEvent.getContent().aliases.filter(a => { + if (typeof(a) !== "string") return false; + if (a[0] !== '#') return false; + if (!a.endsWith(`:${aliasEvent.getStateKey()}`)) return false; + + // It's probably valid by here. + return true; + }); + Array.prototype.push.apply(aliasStrings, filteredAliases); + } + } + } + return aliasStrings; + } + + /** + * Get this room's canonical alias + * The alias returned by this function may not necessarily + * still point to this room. + * @return {?string} The room's canonical alias, or null if there is none + */ + public getCanonicalAlias(): string | null { + const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, ""); + if (canonicalAlias) { + return canonicalAlias.getContent().alias || null; + } + return null; + } + + /** + * Get this room's alternative aliases + * @return {array} The room's alternative aliases, or an empty array + */ + public getAltAliases(): string[] { + const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, ""); + if (canonicalAlias) { + return canonicalAlias.getContent().alt_aliases || []; + } + return []; + } + + /** + * Add events to a timeline + * + *

Will fire "Room.timeline" for each event added. + * + * @param {MatrixEvent[]} events A list of events to add. + * + * @param {boolean} toStartOfTimeline True to add these events to the start + * (oldest) instead of the end (newest) of the timeline. If true, the oldest + * event will be the last element of 'events'. + * + * @param {module:models/event-timeline~EventTimeline} timeline timeline to + * add events to. + * + * @param {string=} paginationToken token for the next batch of events + * + * @fires module:client~MatrixClient#event:"Room.timeline" + * + */ + public addEventsToTimeline( + events: MatrixEvent[], + toStartOfTimeline: boolean, + timeline: EventTimeline, + paginationToken?: string, + ): void { + timeline.getTimelineSet().addEventsToTimeline( + events, toStartOfTimeline, + timeline, paginationToken, + ); + } + + /** + * Get a member from the current room state. + * @param {string} userId The user ID of the member. + * @return {RoomMember} The member or null. + */ + public getMember(userId: string): RoomMember | null { + return this.currentState.getMember(userId); + } + + /** + * Get all currently loaded members from the current + * room state. + * @returns {RoomMember[]} Room members + */ + public getMembers(): RoomMember[] { + return this.currentState.getMembers(); + } + + /** + * Get a list of members whose membership state is "join". + * @return {RoomMember[]} A list of currently joined members. + */ + public getJoinedMembers(): RoomMember[] { + return this.getMembersWithMembership("join"); + } + + /** + * Returns the number of joined members in this room + * This method caches the result. + * This is a wrapper around the method of the same name in roomState, returning + * its result for the room's current state. + * @return {number} The number of members in this room whose membership is 'join' + */ + public getJoinedMemberCount(): number { + return this.currentState.getJoinedMemberCount(); + } + + /** + * Returns the number of invited members in this room + * @return {number} The number of members in this room whose membership is 'invite' + */ + public getInvitedMemberCount(): number { + return this.currentState.getInvitedMemberCount(); + } + + /** + * Returns the number of invited + joined members in this room + * @return {number} The number of members in this room whose membership is 'invite' or 'join' + */ + public getInvitedAndJoinedMemberCount(): number { + return this.getInvitedMemberCount() + this.getJoinedMemberCount(); + } + + /** + * Get a list of members with given membership state. + * @param {string} membership The membership state. + * @return {RoomMember[]} A list of members with the given membership state. + */ + public getMembersWithMembership(membership: string): RoomMember[] { + return this.currentState.getMembers().filter(function(m) { + return m.membership === membership; + }); + } + + /** + * Get a list of members we should be encrypting for in this room + * @return {Promise} A list of members who + * we should encrypt messages for in this room. + */ + public async getEncryptionTargetMembers(): Promise { + await this.loadMembersIfNeeded(); + let members = this.getMembersWithMembership("join"); + if (this.shouldEncryptForInvitedMembers()) { + members = members.concat(this.getMembersWithMembership("invite")); + } + return members; + } + + /** + * Determine whether we should encrypt messages for invited users in this room + * @return {boolean} if we should encrypt messages for invited users + */ + public shouldEncryptForInvitedMembers(): boolean { + const ev = this.currentState.getStateEvents(EventType.RoomHistoryVisibility, ""); + return ev?.getContent()?.history_visibility !== "joined"; + } + + /** + * Get the default room name (i.e. what a given user would see if the + * room had no m.room.name) + * @param {string} userId The userId from whose perspective we want + * to calculate the default name + * @return {string} The default room name + */ + public getDefaultRoomName(userId: string): string { + return this.calculateRoomName(userId, true); + } + + /** + * Check if the given user_id has the given membership state. + * @param {string} userId The user ID to check. + * @param {string} membership The membership e.g. 'join' + * @return {boolean} True if this user_id has the given membership state. + */ + public hasMembershipState(userId: string, membership: string): boolean { + const member = this.getMember(userId); + if (!member) { + return false; + } + return member.membership === membership; + } + + /** + * Add a timelineSet for this room with the given filter + * @param {Filter} filter The filter to be applied to this timelineSet + * @return {EventTimelineSet} The timelineSet + */ + public getOrCreateFilteredTimelineSet(filter: Filter): EventTimelineSet { + if (this.filteredTimelineSets[filter.filterId]) { + return this.filteredTimelineSets[filter.filterId]; + } + const opts = Object.assign({ filter: filter }, this.opts); + const timelineSet = new EventTimelineSet(this, opts); + this.reEmitter.reEmit(timelineSet, ["Room.timeline", "Room.timelineReset"]); + this.filteredTimelineSets[filter.filterId] = timelineSet; + this.timelineSets.push(timelineSet); + + // populate up the new timelineSet with filtered events from our live + // unfiltered timeline. + // + // XXX: This is risky as our timeline + // may have grown huge and so take a long time to filter. + // see https://github.com/vector-im/vector-web/issues/2109 + + const unfilteredLiveTimeline = this.getLiveTimeline(); + + unfilteredLiveTimeline.getEvents().forEach(function(event) { + timelineSet.addLiveEvent(event); + }); + + // find the earliest unfiltered timeline + let timeline = unfilteredLiveTimeline; + while (timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) { + timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); + } + + timelineSet.getLiveTimeline().setPaginationToken( + timeline.getPaginationToken(EventTimeline.BACKWARDS), + EventTimeline.BACKWARDS, + ); + + // alternatively, we could try to do something like this to try and re-paginate + // in the filtered events from nothing, but Mark says it's an abuse of the API + // to do so: + // + // timelineSet.resetLiveTimeline( + // unfilteredLiveTimeline.getPaginationToken(EventTimeline.FORWARDS) + // ); + + return timelineSet; + } + + /** + * Forget the timelineSet for this room with the given filter + * + * @param {Filter} filter the filter whose timelineSet is to be forgotten + */ + public removeFilteredTimelineSet(filter: Filter): void { + const timelineSet = this.filteredTimelineSets[filter.filterId]; + delete this.filteredTimelineSets[filter.filterId]; + const i = this.timelineSets.indexOf(timelineSet); + if (i > -1) { + this.timelineSets.splice(i, 1); + } + } + + /** + * Add an event to the end of this room's live timelines. Will fire + * "Room.timeline". + * + * @param {MatrixEvent} event Event to be added + * @param {string?} duplicateStrategy 'ignore' or 'replace' + * @param {boolean} fromCache whether the sync response came from cache + * @fires module:client~MatrixClient#event:"Room.timeline" + * @private + */ + private addLiveEvent(event: MatrixEvent, duplicateStrategy: "ignore" | "replace", fromCache: boolean): void { + if (event.isRedaction()) { + const redactId = event.event.redacts; + + // if we know about this event, redact its contents now. + const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); + if (redactedEvent) { + redactedEvent.makeRedacted(event); + + // If this is in the current state, replace it with the redacted version + if (redactedEvent.getStateKey()) { + const currentStateEvent = this.currentState.getStateEvents( + redactedEvent.getType(), + redactedEvent.getStateKey(), + ); + if (currentStateEvent.getId() === redactedEvent.getId()) { + this.currentState.setStateEvents([redactedEvent]); + } + } + + this.emit("Room.redaction", event, this); + + // TODO: we stash user displaynames (among other things) in + // RoomMember objects which are then attached to other events + // (in the sender and target fields). We should get those + // RoomMember objects to update themselves when the events that + // they are based on are changed. + } + + // FIXME: apply redactions to notification list + + // NB: We continue to add the redaction event to the timeline so + // clients can say "so and so redacted an event" if they wish to. Also + // this may be needed to trigger an update. + } + + if (event.getUnsigned().transaction_id) { + const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id]; + if (existingEvent) { + // remote echo of an event we sent earlier + this.handleRemoteEcho(event, existingEvent); + return; + } + } + + // add to our timeline sets + for (let i = 0; i < this.timelineSets.length; i++) { + this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); + } + + // synthesize and inject implicit read receipts + // Done after adding the event because otherwise the app would get a read receipt + // pointing to an event that wasn't yet in the timeline + // Don't synthesize RR for m.room.redaction as this causes the RR to go missing. + if (event.sender && event.getType() !== EventType.RoomRedaction) { + this.addReceipt(synthesizeReceipt( + event.sender.userId, event, "m.read", + ), true); + + // Any live events from a user could be taken as implicit + // presence information: evidence that they are currently active. + // ...except in a world where we use 'user.currentlyActive' to reduce + // presence spam, this isn't very useful - we'll get a transition when + // they are no longer currently active anyway. So don't bother to + // reset the lastActiveAgo and lastPresenceTs from the RoomState's user. + } + } + + /** + * Add a pending outgoing event to this room. + * + *

The event is added to either the pendingEventList, or the live timeline, + * depending on the setting of opts.pendingEventOrdering. + * + *

This is an internal method, intended for use by MatrixClient. + * + * @param {module:models/event.MatrixEvent} event The event to add. + * + * @param {string} txnId Transaction id for this outgoing event + * + * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" + * + * @throws if the event doesn't have status SENDING, or we aren't given a + * unique transaction id. + */ + public addPendingEvent(event: MatrixEvent, txnId: string): void { + if (event.status !== EventStatus.SENDING && event.status !== EventStatus.NOT_SENT) { + throw new Error("addPendingEvent called on an event with status " + + event.status); + } + + if (this.txnToEvent[txnId]) { + throw new Error("addPendingEvent called on an event with known txnId " + + txnId); + } + + // call setEventMetadata to set up event.sender etc + // as event is shared over all timelineSets, we set up its metadata based + // on the unfiltered timelineSet. + EventTimeline.setEventMetadata( + event, + this.getLiveTimeline().getState(EventTimeline.FORWARDS), + false, + ); + + this.txnToEvent[txnId] = event; + + if (this.opts.pendingEventOrdering == "detached") { + if (this.pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) { + logger.warn("Setting event as NOT_SENT due to messages in the same state"); + event.setStatus(EventStatus.NOT_SENT); + } + this.pendingEventList.push(event); + this.savePendingEvents(); + if (event.isRelation()) { + // For pending events, add them to the relations collection immediately. + // (The alternate case below already covers this as part of adding to + // the timeline set.) + this.aggregateNonLiveRelation(event); + } + + if (event.isRedaction()) { + const redactId = event.event.redacts; + let redactedEvent = this.pendingEventList && + this.pendingEventList.find(e => e.getId() === redactId); + if (!redactedEvent) { + redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); + } + if (redactedEvent) { + redactedEvent.markLocallyRedacted(event); + this.emit("Room.redaction", event, this); + } + } + } else { + for (let i = 0; i < this.timelineSets.length; i++) { + const timelineSet = this.timelineSets[i]; + if (timelineSet.getFilter()) { + if (timelineSet.getFilter().filterRoomTimeline([event]).length) { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); + } + } else { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); + } + } + } + + this.emit("Room.localEchoUpdated", event, this, null, null); + } + + /** + * Persists all pending events to local storage + * + * If the current room is encrypted only encrypted events will be persisted + * all messages that are not yet encrypted will be discarded + * + * This is because the flow of EVENT_STATUS transition is + * queued => sending => encrypting => sending => sent + * + * Steps 3 and 4 are skipped for unencrypted room. + * It is better to discard an unencrypted message rather than persisting + * it locally for everyone to read + */ + private savePendingEvents(): void { + if (this.pendingEventList) { + const pendingEvents = this.pendingEventList.map(event => { + return { + ...event.event, + txn_id: event.getTxnId(), + }; + }).filter(event => { + // Filter out the unencrypted messages if the room is encrypted + const isEventEncrypted = event.type === EventType.RoomMessageEncrypted; + const isRoomEncrypted = this.client.isRoomEncrypted(this.roomId); + return isEventEncrypted || !isRoomEncrypted; + }); + + const { store } = this.client.sessionStore; + if (this.pendingEventList.length > 0) { + store.setItem( + pendingEventsKey(this.roomId), + JSON.stringify(pendingEvents), + ); + } else { + store.removeItem(pendingEventsKey(this.roomId)); + } + } + } + + /** + * Used to aggregate the local echo for a relation, and also + * for re-applying a relation after it's redaction has been cancelled, + * as the local echo for the redaction of the relation would have + * un-aggregated the relation. Note that this is different from regular messages, + * which are just kept detached for their local echo. + * + * Also note that live events are aggregated in the live EventTimelineSet. + * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. + */ + private aggregateNonLiveRelation(event: MatrixEvent): void { + // TODO: We should consider whether this means it would be a better + // design to lift the relations handling up to the room instead. + for (let i = 0; i < this.timelineSets.length; i++) { + const timelineSet = this.timelineSets[i]; + if (timelineSet.getFilter()) { + if (timelineSet.getFilter().filterRoomTimeline([event]).length) { + timelineSet.aggregateRelations(event); + } + } else { + timelineSet.aggregateRelations(event); + } + } + } + + /** + * Deal with the echo of a message we sent. + * + *

We move the event to the live timeline if it isn't there already, and + * update it. + * + * @param {module:models/event.MatrixEvent} remoteEvent The event received from + * /sync + * @param {module:models/event.MatrixEvent} localEvent The local echo, which + * should be either in the pendingEventList or the timeline. + * + * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" + * @private + */ + private handleRemoteEcho(remoteEvent: MatrixEvent, localEvent: MatrixEvent): void { + const oldEventId = localEvent.getId(); + const newEventId = remoteEvent.getId(); + const oldStatus = localEvent.status; + + logger.debug( + `Got remote echo for event ${oldEventId} -> ${newEventId} ` + + `old status ${oldStatus}`, + ); + + // no longer pending + delete this.txnToEvent[remoteEvent.getUnsigned().transaction_id]; + + // if it's in the pending list, remove it + if (this.pendingEventList) { + this.removePendingEvent(oldEventId); + } + + // replace the event source (this will preserve the plaintext payload if + // any, which is good, because we don't want to try decoding it again). + localEvent.handleRemoteEcho(remoteEvent.event); + + for (let i = 0; i < this.timelineSets.length; i++) { + const timelineSet = this.timelineSets[i]; + + // if it's already in the timeline, update the timeline map. If it's not, add it. + timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); + } + + this.emit("Room.localEchoUpdated", localEvent, this, + oldEventId, oldStatus); + } + + /** + * Update the status / event id on a pending event, to reflect its transmission + * progress. + * + *

This is an internal method. + * + * @param {MatrixEvent} event local echo event + * @param {EventStatus} newStatus status to assign + * @param {string} newEventId new event id to assign. Ignored unless + * newStatus == EventStatus.SENT. + * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" + */ + public updatePendingEvent(event: MatrixEvent, newStatus: EventStatus, newEventId?: string): void { + logger.log( + `setting pendingEvent status to ${newStatus} in ${event.getRoomId()} ` + + `event ID ${event.getId()} -> ${newEventId}`, + ); + + // if the message was sent, we expect an event id + if (newStatus == EventStatus.SENT && !newEventId) { + throw new Error("updatePendingEvent called with status=SENT, " + + "but no new event id"); + } + + // SENT races against /sync, so we have to special-case it. + if (newStatus == EventStatus.SENT) { + const timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId); + if (timeline) { + // we've already received the event via the event stream. + // nothing more to do here. + return; + } + } + + const oldStatus = event.status; + const oldEventId = event.getId(); + + if (!oldStatus) { + throw new Error("updatePendingEventStatus called on an event which is " + + "not a local echo."); + } + + const allowed = ALLOWED_TRANSITIONS[oldStatus]; + if (!allowed || allowed.indexOf(newStatus) < 0) { + throw new Error("Invalid EventStatus transition " + oldStatus + "->" + + newStatus); + } + + event.setStatus(newStatus); + + if (newStatus == EventStatus.SENT) { + // update the event id + event.replaceLocalEventId(newEventId); + + // if the event was already in the timeline (which will be the case if + // opts.pendingEventOrdering==chronological), we need to update the + // timeline map. + for (let i = 0; i < this.timelineSets.length; i++) { + this.timelineSets[i].replaceEventId(oldEventId, newEventId); + } + } else if (newStatus == EventStatus.CANCELLED) { + // remove it from the pending event list, or the timeline. + if (this.pendingEventList) { + const idx = this.pendingEventList.findIndex(ev => ev.getId() === oldEventId); + if (idx !== -1) { + const [removedEvent] = this.pendingEventList.splice(idx, 1); + if (removedEvent.isRedaction()) { + this.revertRedactionLocalEcho(removedEvent); + } + } + } + this.removeEvent(oldEventId); + } + this.savePendingEvents(); + + this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus); + } + + private revertRedactionLocalEcho(redactionEvent: MatrixEvent): void { + const redactId = redactionEvent.event.redacts; + if (!redactId) { + return; + } + const redactedEvent = this.getUnfilteredTimelineSet() + .findEventById(redactId); + if (redactedEvent) { + redactedEvent.unmarkLocallyRedacted(); + // re-render after undoing redaction + this.emit("Room.redactionCancelled", redactionEvent, this); + // reapply relation now redaction failed + if (redactedEvent.isRelation()) { + this.aggregateNonLiveRelation(redactedEvent); + } + } + } + + /** + * Add some events to this room. This can include state events, message + * events and typing notifications. These events are treated as "live" so + * they will go to the end of the timeline. + * + * @param {MatrixEvent[]} events A list of events to add. + * + * @param {string} duplicateStrategy Optional. Applies to events in the + * timeline only. If this is 'replace' then if a duplicate is encountered, the + * event passed to this function will replace the existing event in the + * timeline. If this is not specified, or is 'ignore', then the event passed to + * this function will be ignored entirely, preserving the existing event in the + * timeline. Events are identical based on their event ID only. + * + * @param {boolean} fromCache whether the sync response came from cache + * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. + */ + public addLiveEvents(events: MatrixEvent[], duplicateStrategy: "replace" | "ignore", fromCache: boolean): void { + let i; + if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { + throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); + } + + // sanity check that the live timeline is still live + for (i = 0; i < this.timelineSets.length; i++) { + const liveTimeline = this.timelineSets[i].getLiveTimeline(); + if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { + throw new Error( + "live timeline " + i + " is no longer live - it has a pagination token " + + "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")", + ); + } + if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { + throw new Error( + "live timeline " + i + " is no longer live - " + + "it has a neighbouring timeline", + ); + } + } + + for (i = 0; i < events.length; i++) { + // TODO: We should have a filter to say "only add state event + // types X Y Z to the timeline". + this.addLiveEvent(events[i], duplicateStrategy, fromCache); + } + } + + /** + * Adds/handles ephemeral events such as typing notifications and read receipts. + * @param {MatrixEvent[]} events A list of events to process + */ + public addEphemeralEvents(events: MatrixEvent[]): void { + for (const event of events) { + if (event.getType() === 'm.typing') { + this.currentState.setTypingEvent(event); + } else if (event.getType() === 'm.receipt') { + this.addReceipt(event); + } // else ignore - life is too short for us to care about these events + } + } + + /** + * Removes events from this room. + * @param {String[]} eventIds A list of eventIds to remove. + */ + public removeEvents(eventIds: string[]): void { + for (let i = 0; i < eventIds.length; ++i) { + this.removeEvent(eventIds[i]); + } + } + + /** + * Removes a single event from this room. + * + * @param {String} eventId The id of the event to remove + * + * @return {boolean} true if the event was removed from any of the room's timeline sets + */ + public removeEvent(eventId: string): boolean { + let removedAny = false; + for (let i = 0; i < this.timelineSets.length; i++) { + const removed = this.timelineSets[i].removeEvent(eventId); + if (removed) { + if (removed.isRedaction()) { + this.revertRedactionLocalEcho(removed); + } + removedAny = true; + } + } + return removedAny; + } + + /** + * Recalculate various aspects of the room, including the room name and + * room summary. Call this any time the room's current state is modified. + * May fire "Room.name" if the room name is updated. + * @fires module:client~MatrixClient#event:"Room.name" + */ + public recalculate(): void { + // set fake stripped state events if this is an invite room so logic remains + // consistent elsewhere. + const membershipEvent = this.currentState.getStateEvents(EventType.RoomMember, this.myUserId); + if (membershipEvent && membershipEvent.getContent().membership === "invite") { + const strippedStateEvents = membershipEvent.event.invite_room_state || []; + strippedStateEvents.forEach((strippedEvent) => { + const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key); + if (!existingEvent) { + // set the fake stripped event instead + this.currentState.setStateEvents([new MatrixEvent({ + type: strippedEvent.type, + state_key: strippedEvent.state_key, + content: strippedEvent.content, + event_id: "$fake" + Date.now(), + room_id: this.roomId, + user_id: this.myUserId, // technically a lie + })]); + } + }); + } + + const oldName = this.name; + this.name = this.calculateRoomName(this.myUserId); + this.normalizedName = normalize(this.name); + this.summary = new RoomSummary(this.roomId, { + title: this.name, + }); + + if (oldName !== this.name) { + this.emit("Room.name", this); + } + } + + /** + * Get a list of user IDs who have read up to the given event. + * @param {MatrixEvent} event the event to get read receipts for. + * @return {String[]} A list of user IDs. + */ + public getUsersReadUpTo(event: MatrixEvent): string[] { + return this.getReceiptsForEvent(event).filter(function(receipt) { + return receipt.type === "m.read"; + }).map(function(receipt) { + return receipt.userId; + }); + } + + /** + * Get the ID of the event that a given user has read up to, or null if we + * have received no read receipts from them. + * @param {String} userId The user ID to get read receipt event ID for + * @param {Boolean} ignoreSynthesized If true, return only receipts that have been + * sent by the server, not implicit ones generated + * by the JS SDK. + * @return {String} ID of the latest event that the given user has read, or null. + */ + public getEventReadUpTo(userId: string, ignoreSynthesized: boolean): string | null { + let receipts = this.receipts; + if (ignoreSynthesized) { + receipts = this.realReceipts; + } + + if ( + receipts["m.read"] === undefined || + receipts["m.read"][userId] === undefined + ) { + return null; + } + + return receipts["m.read"][userId].eventId; + } + + /** + * Determines if the given user has read a particular event ID with the known + * history of the room. This is not a definitive check as it relies only on + * what is available to the room at the time of execution. + * @param {String} userId The user ID to check the read state of. + * @param {String} eventId The event ID to check if the user read. + * @returns {Boolean} True if the user has read the event, false otherwise. + */ + public hasUserReadEvent(userId: string, eventId: string): boolean { + const readUpToId = this.getEventReadUpTo(userId, false); + if (readUpToId === eventId) return true; + + if (this.timeline.length + && this.timeline[this.timeline.length - 1].getSender() + && this.timeline[this.timeline.length - 1].getSender() === userId) { + // It doesn't matter where the event is in the timeline, the user has read + // it because they've sent the latest event. + return true; + } + + for (let i = this.timeline.length - 1; i >= 0; --i) { + const ev = this.timeline[i]; + + // If we encounter the target event first, the user hasn't read it + // however if we encounter the readUpToId first then the user has read + // it. These rules apply because we're iterating bottom-up. + if (ev.getId() === eventId) return false; + if (ev.getId() === readUpToId) return true; + } + + // We don't know if the user has read it, so assume not. + return false; + } + + /** + * Get a list of receipts for the given event. + * @param {MatrixEvent} event the event to get receipts for + * @return {Object[]} A list of receipts with a userId, type and data keys or + * an empty list. + */ + public getReceiptsForEvent(event: MatrixEvent): ICachedReceipt[] { + return this.receiptCacheByEventId[event.getId()] || []; + } + + /** + * Add a receipt event to the room. + * @param {MatrixEvent} event The m.receipt event. + * @param {Boolean} fake True if this event is implicit + */ + public addReceipt(event: MatrixEvent, fake = false): void { + if (!fake) { + this.addReceiptsToStructure(event, this.realReceipts); + // we don't bother caching real receipts by event ID + // as there's nothing that would read it. + } + this.addReceiptsToStructure(event, this.receipts); + this.receiptCacheByEventId = this.buildReceiptCache(this.receipts); + + // send events after we've regenerated the cache, otherwise things that + // listened for the event would read from a stale cache + this.emit("Room.receipt", event, this); + } + + /** + * Add a receipt event to the room. + * @param {MatrixEvent} event The m.receipt event. + * @param {Object} receipts The object to add receipts to + */ + private addReceiptsToStructure(event: MatrixEvent, receipts: Receipts): void { + const content = event.getContent(); + Object.keys(content).forEach((eventId) => { + Object.keys(content[eventId]).forEach((receiptType) => { + Object.keys(content[eventId][receiptType]).forEach((userId) => { + const receipt = content[eventId][receiptType][userId]; + + if (!receipts[receiptType]) { + receipts[receiptType] = {}; + } + + const existingReceipt = receipts[receiptType][userId]; + + if (!existingReceipt) { + receipts[receiptType][userId] = {} as IWrappedReceipt; + } else { + // we only want to add this receipt if we think it is later + // than the one we already have. (This is managed + // server-side, but because we synthesize RRs locally we + // have to do it here too.) + const ordering = this.getUnfilteredTimelineSet().compareEventOrdering( + existingReceipt.eventId, eventId); + if (ordering !== null && ordering >= 0) { + return; + } + } + + receipts[receiptType][userId] = { + eventId: eventId, + data: receipt, + }; + }); + }); + }); + } + + /** + * Build and return a map of receipts by event ID + * @param {Object} receipts A map of receipts + * @return {Object} Map of receipts by event ID + */ + private buildReceiptCache(receipts: Receipts): ReceiptCache { + const receiptCacheByEventId = {}; + Object.keys(receipts).forEach(function(receiptType) { + Object.keys(receipts[receiptType]).forEach(function(userId) { + const receipt = receipts[receiptType][userId]; + if (!receiptCacheByEventId[receipt.eventId]) { + receiptCacheByEventId[receipt.eventId] = []; + } + receiptCacheByEventId[receipt.eventId].push({ + userId: userId, + type: receiptType, + data: receipt.data, + }); + }); + }); + return receiptCacheByEventId; + } + + /** + * Add a temporary local-echo receipt to the room to reflect in the + * client the fact that we've sent one. + * @param {string} userId The user ID if the receipt sender + * @param {MatrixEvent} e The event that is to be acknowledged + * @param {string} receiptType The type of receipt + */ + public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: string): void { + this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); + } + + /** + * Update the room-tag event for the room. The previous one is overwritten. + * @param {MatrixEvent} event the m.tag event + */ + public addTags(event: MatrixEvent): void { + // event content looks like: + // content: { + // tags: { + // $tagName: { $metadata: $value }, + // $tagName: { $metadata: $value }, + // } + // } + + // XXX: do we need to deep copy here? + this.tags = event.getContent().tags || {}; + + // XXX: we could do a deep-comparison to see if the tags have really + // changed - but do we want to bother? + this.emit("Room.tags", event, this); + } + + /** + * Update the account_data events for this room, overwriting events of the same type. + * @param {Array} events an array of account_data events to add + */ + public addAccountData(events: MatrixEvent[]): void { + for (let i = 0; i < events.length; i++) { + const event = events[i]; + if (event.getType() === "m.tag") { + this.addTags(event); + } + const lastEvent = this.accountData[event.getType()]; + this.accountData[event.getType()] = event; + this.emit("Room.accountData", event, this, lastEvent); + } + } + + /** + * Access account_data event of given event type for this room + * @param {string} type the type of account_data event to be accessed + * @return {?MatrixEvent} the account_data event in question + */ + public getAccountData(type: EventType | string): MatrixEvent | undefined { + return this.accountData[type]; + } + + /** + * Returns whether the syncing user has permission to send a message in the room + * @return {boolean} true if the user should be permitted to send + * message events into the room. + */ + public maySendMessage(): boolean { + return this.getMyMembership() === 'join' && + this.currentState.maySendEvent(EventType.RoomMessage, this.myUserId); + } + + /** + * Returns whether the given user has permissions to issue an invite for this room. + * @param {string} userId the ID of the Matrix user to check permissions for + * @returns {boolean} true if the user should be permitted to issue invites for this room. + */ + public canInvite(userId: string): boolean { + let canInvite = this.getMyMembership() === "join"; + const powerLevelsEvent = this.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + const powerLevels = powerLevelsEvent && powerLevelsEvent.getContent(); + const me = this.getMember(userId); + if (powerLevels && me && powerLevels.invite > me.powerLevel) { + canInvite = false; + } + return canInvite; + } + + /** + * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. + * @returns {string} the join_rule applied to this room + */ + public getJoinRule(): string { + return this.currentState.getJoinRule(); + } + + /** + * Returns the type of the room from the `m.room.create` event content or undefined if none is set + * @returns {?string} the type of the room. Currently only RoomType.Space is known. + */ + public getType(): RoomType | string | undefined { + const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); + if (!createEvent) { + if (!this.getTypeWarning) { + logger.warn("[getType] Room " + this.roomId + " does not have an m.room.create event"); + this.getTypeWarning = true; + } + return undefined; + } + return createEvent.getContent()[RoomCreateTypeField]; + } + + /** + * Returns whether the room is a space-room as defined by MSC1772. + * @returns {boolean} true if the room's type is RoomType.Space + */ + public isSpaceRoom(): boolean { + return this.getType() === RoomType.Space; + } + + /** + * This is an internal method. Calculates the name of the room from the current + * room state. + * @param {string} userId The client's user ID. Used to filter room members + * correctly. + * @param {boolean} ignoreRoomNameEvent Return the implicit room name that we'd see if there + * was no m.room.name event. + * @return {string} The calculated room name. + */ + private calculateRoomName(userId: string, ignoreRoomNameEvent = false): string { + if (!ignoreRoomNameEvent) { + // check for an alias, if any. for now, assume first alias is the + // official one. + const mRoomName = this.currentState.getStateEvents(EventType.RoomName, ""); + if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) { + return mRoomName.getContent().name; + } + } + + let alias = this.getCanonicalAlias(); + + if (!alias) { + const aliases = this.getAltAliases(); + + if (aliases.length) { + alias = aliases[0]; + } + } + if (alias) { + return alias; + } + + const joinedMemberCount = this.currentState.getJoinedMemberCount(); + const invitedMemberCount = this.currentState.getInvitedMemberCount(); + // -1 because these numbers include the syncing user + const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; + + // get members that are NOT ourselves and are actually in the room. + let otherNames = null; + if (this.summaryHeroes) { + // if we have a summary, the member state events + // should be in the room state + otherNames = this.summaryHeroes.map((userId) => { + const member = this.getMember(userId); + return member ? member.name : userId; + }); + } else { + let otherMembers = this.currentState.getMembers().filter((m) => { + return m.userId !== userId && + (m.membership === "invite" || m.membership === "join"); + }); + // make sure members have stable order + otherMembers.sort((a, b) => a.userId.localeCompare(b.userId)); + // only 5 first members, immitate summaryHeroes + otherMembers = otherMembers.slice(0, 5); + otherNames = otherMembers.map((m) => m.name); + } + + if (inviteJoinCount) { + return memberNamesToRoomName(otherNames, inviteJoinCount); + } + + const myMembership = this.getMyMembership(); + // if I have created a room and invited people throuh + // 3rd party invites + if (myMembership == 'join') { + const thirdPartyInvites = + this.currentState.getStateEvents(EventType.RoomThirdPartyInvite); + + if (thirdPartyInvites && thirdPartyInvites.length) { + const thirdPartyNames = thirdPartyInvites.map((i) => { + return i.getContent().display_name; + }); + + return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`; + } + } + // let's try to figure out who was here before + let leftNames = otherNames; + // if we didn't have heroes, try finding them in the room state + if (!leftNames.length) { + leftNames = this.currentState.getMembers().filter((m) => { + return m.userId !== userId && + m.membership !== "invite" && + m.membership !== "join"; + }).map((m) => m.name); + } + if (leftNames.length) { + return `Empty room (was ${memberNamesToRoomName(leftNames)})`; + } else { + return "Empty room"; + } + } +} + +/** + * @param {string} roomId ID of the current room + * @returns {string} Storage key to retrieve pending events + */ +function pendingEventsKey(roomId: string): string { + return `mx_pending_events_${roomId}`; +} + +/* a map from current event status to a list of allowed next statuses + */ +const ALLOWED_TRANSITIONS = {}; + +ALLOWED_TRANSITIONS[EventStatus.ENCRYPTING] = [ + EventStatus.SENDING, + EventStatus.NOT_SENT, +]; + +ALLOWED_TRANSITIONS[EventStatus.SENDING] = [ + EventStatus.ENCRYPTING, + EventStatus.QUEUED, + EventStatus.NOT_SENT, + EventStatus.SENT, +]; + +ALLOWED_TRANSITIONS[EventStatus.QUEUED] = + [EventStatus.SENDING, EventStatus.CANCELLED]; + +ALLOWED_TRANSITIONS[EventStatus.SENT] = + []; + +ALLOWED_TRANSITIONS[EventStatus.NOT_SENT] = + [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED]; + +ALLOWED_TRANSITIONS[EventStatus.CANCELLED] = + []; + +// TODO i18n +function memberNamesToRoomName(names: string[], count = (names.length + 1)) { + const countWithoutMe = count - 1; + if (!names.length) { + return "Empty room"; + } else if (names.length === 1 && countWithoutMe <= 1) { + return names[0]; + } else if (names.length === 2 && countWithoutMe <= 2) { + return `${names[0]} and ${names[1]}`; + } else { + const plural = countWithoutMe > 1; + if (plural) { + return `${names[0]} and ${countWithoutMe} others`; + } else { + return `${names[0]} and 1 other`; + } + } +} + +/** + * Fires when an event we had previously received is redacted. + * + * (Note this is *not* fired when the redaction happens before we receive the + * event). + * + * @event module:client~MatrixClient#"Room.redaction" + * @param {MatrixEvent} event The matrix redaction event + * @param {Room} room The room containing the redacted event + */ + +/** + * Fires when an event that was previously redacted isn't anymore. + * This happens when the redaction couldn't be sent and + * was subsequently cancelled by the user. Redactions have a local echo + * which is undone in this scenario. + * + * @event module:client~MatrixClient#"Room.redactionCancelled" + * @param {MatrixEvent} event The matrix redaction event that was cancelled. + * @param {Room} room The room containing the unredacted event + */ + +/** + * Fires whenever the name of a room is updated. + * @event module:client~MatrixClient#"Room.name" + * @param {Room} room The room whose Room.name was updated. + * @example + * matrixClient.on("Room.name", function(room){ + * var newName = room.name; + * }); + */ + +/** + * Fires whenever a receipt is received for a room + * @event module:client~MatrixClient#"Room.receipt" + * @param {event} event The receipt event + * @param {Room} room The room whose receipts was updated. + * @example + * matrixClient.on("Room.receipt", function(event, room){ + * var receiptContent = event.getContent(); + * }); + */ + +/** + * Fires whenever a room's tags are updated. + * @event module:client~MatrixClient#"Room.tags" + * @param {event} event The tags event + * @param {Room} room The room whose Room.tags was updated. + * @example + * matrixClient.on("Room.tags", function(event, room){ + * var newTags = event.getContent().tags; + * if (newTags["favourite"]) showStar(room); + * }); + */ + +/** + * Fires whenever a room's account_data is updated. + * @event module:client~MatrixClient#"Room.accountData" + * @param {event} event The account_data event + * @param {Room} room The room whose account_data was updated. + * @param {MatrixEvent} prevEvent The event being replaced by + * the new account data, if known. + * @example + * matrixClient.on("Room.accountData", function(event, room, oldEvent){ + * if (event.getType() === "m.room.colorscheme") { + * applyColorScheme(event.getContents()); + * } + * }); + */ + +/** + * Fires when the status of a transmitted event is updated. + * + *

When an event is first transmitted, a temporary copy of the event is + * inserted into the timeline, with a temporary event id, and a status of + * 'SENDING'. + * + *

Once the echo comes back from the server, the content of the event + * (MatrixEvent.event) is replaced by the complete event from the homeserver, + * thus updating its event id, as well as server-generated fields such as the + * timestamp. Its status is set to null. + * + *

Once the /send request completes, if the remote echo has not already + * arrived, the event is updated with a new event id and the status is set to + * 'SENT'. The server-generated fields are of course not updated yet. + * + *

If the /send fails, In this case, the event's status is set to + * 'NOT_SENT'. If it is later resent, the process starts again, setting the + * status to 'SENDING'. Alternatively, the message may be cancelled, which + * removes the event from the room, and sets the status to 'CANCELLED'. + * + *

This event is raised to reflect each of the transitions above. + * + * @event module:client~MatrixClient#"Room.localEchoUpdated" + * + * @param {MatrixEvent} event The matrix event which has been updated + * + * @param {Room} room The room containing the redacted event + * + * @param {string} oldEventId The previous event id (the temporary event id, + * except when updating a successfully-sent event when its echo arrives) + * + * @param {EventStatus} oldStatus The previous event status. + */ + +/** + * Fires when the logged in user's membership in the room is updated. + * + * @event module:models/room~Room#"Room.myMembership" + * @param {Room} room The room in which the membership has been updated + * @param {string} membership The new membership value + * @param {string} prevMembership The previous membership value + */ From cbce2f46c333d20cc1f61377d755c5892c069466 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 18 Jun 2021 15:30:30 +0100 Subject: [PATCH 072/124] Fix position of invite_room_state on the event --- spec/unit/room.spec.js | 4 ++-- src/models/room.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 90d65ba8f..69b53a2b1 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -652,7 +652,7 @@ describe("Room", function() { const roomName = "flibble"; const event = addMember(userA, "invite"); - event.event.invite_room_state = [ + event.event.unsigned.invite_room_state = [ { type: "m.room.name", state_key: "", @@ -671,7 +671,7 @@ describe("Room", function() { const roomName = "flibble"; setRoomName(roomName); const roomNameToIgnore = "ignoreme"; - event.event.invite_room_state = [ + event.event.unsigned.invite_room_state = [ { type: "m.room.name", state_key: "", diff --git a/src/models/room.ts b/src/models/room.ts index 787b409d6..9b271c4fe 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1698,7 +1698,7 @@ export class Room extends EventEmitter { // consistent elsewhere. const membershipEvent = this.currentState.getStateEvents(EventType.RoomMember, this.myUserId); if (membershipEvent && membershipEvent.getContent().membership === "invite") { - const strippedStateEvents = membershipEvent.event.invite_room_state || []; + const strippedStateEvents = membershipEvent.getUnsigned().invite_room_state || []; strippedStateEvents.forEach((strippedEvent) => { const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key); if (!existingEvent) { From b760fa0ff5037c3a80d4b75e63db8d72956427a0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 18 Jun 2021 15:30:53 +0100 Subject: [PATCH 073/124] Switch typescript target to es2020 as we use new things like Promise.allSettled and it needs it --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 548bbe7fb..3bcac0171 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "esModuleInterop": true, "module": "commonjs", "moduleResolution": "node", - "target": "es2016", + "target": "es2020", "noImplicitAny": false, "sourceMap": true, "outDir": "./lib", From 9e11da1fa5a7d289ea1457e43e4e8ea8208e2b80 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 18 Jun 2021 16:11:11 +0100 Subject: [PATCH 074/124] Fix typing --- src/models/room.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/room.ts b/src/models/room.ts index 9b271c4fe..0689c1fb7 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1749,7 +1749,7 @@ export class Room extends EventEmitter { * by the JS SDK. * @return {String} ID of the latest event that the given user has read, or null. */ - public getEventReadUpTo(userId: string, ignoreSynthesized: boolean): string | null { + public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null { let receipts = this.receipts; if (ignoreSynthesized) { receipts = this.realReceipts; From 924b8629d837b1b0a472b43bc1df36fb4ea50e6e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 18 Jun 2021 16:29:10 +0100 Subject: [PATCH 075/124] Updates around the use of private fields out of class --- src/models/relations.ts | 2 +- src/models/room.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/relations.ts b/src/models/relations.ts index adefc71fe..288ef3616 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -336,7 +336,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.getDecryptionPromise(); } diff --git a/src/models/room.ts b/src/models/room.ts index 0689c1fb7..64e2366a1 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -208,7 +208,7 @@ export class Room extends EventEmitter { */ constructor( public readonly roomId: string, - private readonly client: MatrixClient, + public readonly client: MatrixClient, public readonly myUserId: string, private readonly opts: IOpts = {}, ) { From 311edb4e4c1db29c0bfee9644c169bd4fe789f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Fri, 18 Jun 2021 17:30:50 +0200 Subject: [PATCH 076/124] Add PR template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- .github/PULL_REQUEST_TEMPLATE.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..c9d11f02c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ + + + From 9dd00c7731f6fb15d7c548d66c5d78c1197d1ce7 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 18 Jun 2021 17:40:04 +0100 Subject: [PATCH 077/124] Fix tests and tweak some optional types --- spec/test-utils.js | 2 +- spec/unit/room.spec.js | 18 +++++++++++------- src/models/room.ts | 14 +++++--------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/spec/test-utils.js b/spec/test-utils.js index dcde2d6fe..47b2624f6 100644 --- a/spec/test-utils.js +++ b/spec/test-utils.js @@ -51,7 +51,7 @@ export function mock(constr, name) { result.toString = function() { return "mock" + (name ? " of " + name : ""); }; - for (const key in constr.prototype) { // eslint-disable-line guard-for-in + for (const key of Object.getOwnPropertyNames(constr.prototype)) { // eslint-disable-line guard-for-in try { if (constr.prototype[key] instanceof Function) { result[key] = jest.fn(); diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 69b53a2b1..34ec05f24 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -1,8 +1,8 @@ import * as utils from "../test-utils"; -import { EventStatus, MatrixEvent } from "../../src/models/event"; +import { EventStatus, MatrixEvent } from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; -import { RoomState } from "../../src/models/room-state"; -import { Room } from "../../src/models/room"; +import { RoomState } from "../../src"; +import { Room } from "../../src"; import { TestClient } from "../TestClient"; describe("Room", function() { @@ -86,9 +86,11 @@ describe("Room", function() { ]; it("should call RoomState.setTypingEvent on m.typing events", function() { - room.currentState = utils.mock(RoomState); const typing = utils.mkEvent({ - room: roomId, type: "m.typing", event: true, content: { + room: roomId, + type: "m.typing", + event: true, + content: { user_ids: [userA], }, }); @@ -140,8 +142,8 @@ describe("Room", function() { expect(callCount).toEqual(2); }); - it("should call setStateEvents on the right RoomState with the right " + - "forwardLooking value for new events", function() { + it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", + function() { const events = [ utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, @@ -652,6 +654,7 @@ describe("Room", function() { const roomName = "flibble"; const event = addMember(userA, "invite"); + event.event.unsigned = {}; event.event.unsigned.invite_room_state = [ { type: "m.room.name", @@ -671,6 +674,7 @@ describe("Room", function() { const roomName = "flibble"; setRoomName(roomName); const roomNameToIgnore = "ignoreme"; + event.event.unsigned = {}; event.event.unsigned.invite_room_state = [ { type: "m.room.name", diff --git a/src/models/room.ts b/src/models/room.ts index 64e2366a1..16b8fcac8 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -284,7 +284,7 @@ export class Room extends EventEmitter { .reverse() .map(event => event.attemptDecryption(this.client.crypto, { isRetry: true })); - return Promise.allSettled(decryptionPromises); + return Promise.allSettled(decryptionPromises) as Promise; } /** @@ -301,7 +301,7 @@ export class Room extends EventEmitter { .reverse() .map(event => event.attemptDecryption(this.client.crypto, { isRetry: true })); - return Promise.allSettled(decryptionPromises); + return Promise.allSettled(decryptionPromises) as Promise; } /** @@ -1237,7 +1237,7 @@ export class Room extends EventEmitter { * @fires module:client~MatrixClient#event:"Room.timeline" * @private */ - private addLiveEvent(event: MatrixEvent, duplicateStrategy: "ignore" | "replace", fromCache: boolean): void { + private addLiveEvent(event: MatrixEvent, duplicateStrategy?: "ignore" | "replace", fromCache = false): void { if (event.isRedaction()) { const redactId = event.event.redacts; @@ -1336,11 +1336,7 @@ export class Room extends EventEmitter { // call setEventMetadata to set up event.sender etc // as event is shared over all timelineSets, we set up its metadata based // on the unfiltered timelineSet. - EventTimeline.setEventMetadata( - event, - this.getLiveTimeline().getState(EventTimeline.FORWARDS), - false, - ); + EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(EventTimeline.FORWARDS), false); this.txnToEvent[txnId] = event; @@ -1612,7 +1608,7 @@ export class Room extends EventEmitter { * @param {boolean} fromCache whether the sync response came from cache * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. */ - public addLiveEvents(events: MatrixEvent[], duplicateStrategy: "replace" | "ignore", fromCache: boolean): void { + public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: "replace" | "ignore", fromCache = false): void { let i; if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); From 59c1edb623445fdca7502787f2c96eac22a75ef8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 18 Jun 2021 17:56:18 +0100 Subject: [PATCH 078/124] fix tests --- src/models/room.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index 16b8fcac8..9970ae595 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -227,7 +227,6 @@ export class Room extends EventEmitter { } this.name = roomId; - this.normalizedName = normalize(this.name); // all our per-room timeline sets. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. @@ -284,7 +283,7 @@ export class Room extends EventEmitter { .reverse() .map(event => event.attemptDecryption(this.client.crypto, { isRetry: true })); - return Promise.allSettled(decryptionPromises) as Promise; + return Promise.allSettled(decryptionPromises) as unknown as Promise; } /** @@ -301,7 +300,7 @@ export class Room extends EventEmitter { .reverse() .map(event => event.attemptDecryption(this.client.crypto, { isRetry: true })); - return Promise.allSettled(decryptionPromises) as Promise; + return Promise.allSettled(decryptionPromises) as unknown as Promise; } /** From f502233ddc993cbf3d949c81e6e957a5c3bc7a99 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 18 Jun 2021 11:14:43 -0600 Subject: [PATCH 079/124] try to add more docs --- src/models/event.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/models/event.js b/src/models/event.js index cce346db0..642c7e501 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -1150,7 +1150,10 @@ utils.extend(MatrixEvent.prototype, { * back to this instance, though will have "frozen" event information. Other * properties of this MatrixEvent instance will be copied verbatim, which can * mean they are in reference to this instance despite being on the copy too. - * Consumers should be wary of using fields which may mutate over time. + * The reference the snapshot uses does not change, however members aside from + * the underlying event will not be deeply cloned, thus may be mutated internally. + * For example, the sender profile will be copied over at snapshot time, and + * the sender profile internally may mutate without notice to the consumer. * * This is meant to be used to snapshot the event details themselves, not the * features (such as sender) surrounding the event. From 47e972a66cbafd1ae3d28fd79edbb1b05061b4d8 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 18 Jun 2021 11:18:29 -0600 Subject: [PATCH 080/124] Incorporate merge conflict resolution --- src/models/event.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/models/event.ts b/src/models/event.ts index 246390629..ba16939cc 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -26,6 +26,7 @@ import { logger } from '../logger'; import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; import { EventType, RelationType } from "../@types/event"; import { Crypto } from "../crypto"; +import { deepSortedObjectEntries } from "../utils"; /** * Enum for event statuses. @@ -1176,6 +1177,45 @@ export class MatrixEvent extends EventEmitter { return this._isCancelled; } + /** + * Get a copy/snapshot of this event. The returned copy will be loosely linked + * back to this instance, though will have "frozen" event information. Other + * properties of this MatrixEvent instance will be copied verbatim, which can + * mean they are in reference to this instance despite being on the copy too. + * The reference the snapshot uses does not change, however members aside from + * the underlying event will not be deeply cloned, thus may be mutated internally. + * For example, the sender profile will be copied over at snapshot time, and + * the sender profile internally may mutate without notice to the consumer. + * + * This is meant to be used to snapshot the event details themselves, not the + * features (such as sender) surrounding the event. + * @returns {MatrixEvent} A snapshot of this event. + */ + toSnapshot(): MatrixEvent { + const ev = new MatrixEvent(JSON.parse(JSON.stringify(this.event))); + for (const [p, v] of Object.entries(this)) { + if (p !== "event") { // exclude the thing we just cloned + ev[p] = v; + } + } + return ev; + } + + /** + * Determines if this event is equivalent to the given event. This only checks + * the event object itself, not the other properties of the event. Intended for + * use with toSnapshot() to identify events changing. + * @param {MatrixEvent} otherEvent The other event to check against. + * @returns {boolean} True if the events are the same, false otherwise. + */ + isEquivalentTo(otherEvent: MatrixEvent): boolean { + if (!otherEvent) return false; + if (otherEvent === this) return true; + const myProps = deepSortedObjectEntries(this.event); + const theirProps = deepSortedObjectEntries(otherEvent.event); + return JSON.stringify(myProps) === JSON.stringify(theirProps); + } + /** * Summarise the event as JSON for debugging. If encrypted, include both the * decrypted and encrypted view of the event. This is named `toJSON` for use From fc67dc6497c87d7b6efef25de99da47e4d99a650 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sat, 19 Jun 2021 19:41:45 +0100 Subject: [PATCH 081/124] Convert crypto index to TS --- src/client.ts | 77 +- src/crypto/api.ts | 2 +- src/crypto/backup.ts | 49 +- src/crypto/dehydration.ts | 2 +- src/crypto/index.js | 3651 ------------------------------------ src/crypto/index.ts | 3745 +++++++++++++++++++++++++++++++++++++ src/crypto/keybackup.ts | 13 +- 7 files changed, 3815 insertions(+), 3724 deletions(-) delete mode 100644 src/crypto/index.js create mode 100644 src/crypto/index.ts diff --git a/src/client.ts b/src/client.ts index 1d54523ca..58a3ab3ff 100644 --- a/src/client.ts +++ b/src/client.ts @@ -47,7 +47,8 @@ import { PREFIX_UNSTABLE, retryNetworkOperation, } from "./http-api"; -import { Crypto, DeviceInfo, fixBackupKey, isCryptoAvailable } from './crypto'; +import { Crypto, fixBackupKey, IBootstrapCrossSigningOpts, isCryptoAvailable } from './crypto'; +import { DeviceInfo } from "./crypto/DeviceInfo"; import { decodeRecoveryKey } from './crypto/recoverykey'; import { keyFromAuthData } from './crypto/key_passphrase'; import { User } from "./models/user"; @@ -58,7 +59,6 @@ import { IKeyBackupPrepareOpts, IKeyBackupRestoreOpts, IKeyBackupRestoreResult, - IKeyBackupTrustInfo, IKeyBackupVersion, } from "./crypto/keybackup"; import { IIdentityServerProvider } from "./@types/IIdentityServerProvider"; @@ -114,7 +114,7 @@ import url from "url"; import { randomString } from "./randomstring"; import { ReadStream } from "fs"; import { WebStorageSessionStore } from "./store/session/webstorage"; -import { BackupManager } from "./crypto/backup"; +import { BackupManager, IKeyBackupCheck, TrustInfo } from "./crypto/backup"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; export type Store = StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; @@ -141,6 +141,12 @@ interface IExportedDevice { deviceId: string; } +export interface IKeysUploadResponse { + one_time_key_counts: { // eslint-disable-line camelcase + [algorithm: string]: number; + }; +} + export interface ICreateClientOpts { baseUrl: string; @@ -836,7 +842,7 @@ export class MatrixClient extends EventEmitter { return; } // XXX: Private member access. - return await this.crypto._dehydrationManager.setKeyAndQueueDehydration( + return await this.crypto.dehydrationManager.setKeyAndQueueDehydration( key, keyInfo, deviceDisplayName, ); } @@ -859,11 +865,11 @@ export class MatrixClient extends EventEmitter { logger.warn('not dehydrating device if crypto is not enabled'); return; } - await this.crypto._dehydrationManager.setKey( + await this.crypto.dehydrationManager.setKey( key, keyInfo, deviceDisplayName, ); // XXX: Private member access. - return await this.crypto._dehydrationManager.dehydrateDevice(); + return await this.crypto.dehydrationManager.dehydrateDevice(); } public async exportDevice(): Promise { @@ -875,7 +881,7 @@ export class MatrixClient extends EventEmitter { userId: this.credentials.userId, deviceId: this.deviceId, // XXX: Private member access. - olmDevice: await this.crypto._olmDevice.export(), + olmDevice: await this.crypto.olmDevice.export(), }; } @@ -1239,12 +1245,12 @@ export class MatrixClient extends EventEmitter { * Upload the device keys to the homeserver. * @return {Promise} A promise that will resolve when the keys are uploaded. */ - public uploadKeys(): Promise { + public async uploadKeys(): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } - return this.crypto.uploadDeviceKeys(); + await this.crypto.uploadDeviceKeys(); } /** @@ -1631,7 +1637,7 @@ export class MatrixClient extends EventEmitter { * return true. * @return {boolean} True if cross-signing is ready to be used on this device */ - public isCrossSigningReady(): boolean { + public isCrossSigningReady(): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1658,10 +1664,7 @@ export class MatrixClient extends EventEmitter { * auth data as an object. Can be called multiple times, first with an empty * authDict, to obtain the flows. */ - public bootstrapCrossSigning(opts: { - authUploadDeviceSigningKeys: (makeRequest: (authData: any) => void) => Promise, - setupNewCrossSigning?: boolean, - }) { + public bootstrapCrossSigning(opts: IBootstrapCrossSigningOpts) { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1756,7 +1759,7 @@ export class MatrixClient extends EventEmitter { * * @return {boolean} True if secret storage is ready to be used on this device */ - public isSecretStorageReady(): boolean { + public isSecretStorageReady(): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1848,7 +1851,7 @@ export class MatrixClient extends EventEmitter { * * @return {string} the contents of the secret */ - public getSecret(name: string): string { + public getSecret(name: string): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1885,7 +1888,7 @@ export class MatrixClient extends EventEmitter { * * @return {string} the contents of the secret */ - public requestSecret(name: string, devices: string[]): string { + public requestSecret(name: string, devices: string[]): any { // TODO types if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1899,7 +1902,7 @@ export class MatrixClient extends EventEmitter { * * @return {string} The default key ID or null if no default key ID is set */ - public getDefaultSecretStorageKeyId(): string { + public getDefaultSecretStorageKeyId(): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2075,8 +2078,8 @@ export class MatrixClient extends EventEmitter { * trust information (as returned by isKeyBackupTrusted) * in trustInfo. */ - public checkKeyBackup(): IKeyBackupVersion { - return this.crypto._backupManager.checkKeyBackup(); + public checkKeyBackup(): Promise { + return this.crypto.backupManager.checkKeyBackup(); } /** @@ -2117,8 +2120,8 @@ export class MatrixClient extends EventEmitter { * ] * } */ - public isKeyBackupTrusted(info: IKeyBackupVersion): IKeyBackupTrustInfo { - return this.crypto._backupManager.isKeyBackupTrusted(info); + public isKeyBackupTrusted(info: IKeyBackupVersion): Promise { + return this.crypto.backupManager.isKeyBackupTrusted(info); } /** @@ -2130,7 +2133,7 @@ export class MatrixClient extends EventEmitter { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } - return this.crypto._backupManager.getKeyBackupEnabled(); + return this.crypto.backupManager.getKeyBackupEnabled(); } /** @@ -2145,7 +2148,7 @@ export class MatrixClient extends EventEmitter { throw new Error("End-to-end encryption disabled"); } - return this.crypto._backupManager.enableKeyBackup(info); + return this.crypto.backupManager.enableKeyBackup(info); } /** @@ -2156,7 +2159,7 @@ export class MatrixClient extends EventEmitter { throw new Error("End-to-end encryption disabled"); } - this.crypto._backupManager.disableKeyBackup(); + this.crypto.backupManager.disableKeyBackup(); } /** @@ -2184,7 +2187,7 @@ export class MatrixClient extends EventEmitter { // eslint-disable-next-line camelcase const { algorithm, auth_data, recovery_key, privateKey } = - await this.crypto._backupManager.prepareKeyBackupVersion(password); + await this.crypto.backupManager.prepareKeyBackupVersion(password); if (opts.secureSecretStorage) { await this.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey)); @@ -2221,7 +2224,7 @@ export class MatrixClient extends EventEmitter { throw new Error("End-to-end encryption disabled"); } - await this.crypto._backupManager.createKeyBackupVersion(info); + await this.crypto.backupManager.createKeyBackupVersion(info); const data = { algorithm: info.algorithm, @@ -2232,19 +2235,19 @@ export class MatrixClient extends EventEmitter { // older devices with cross-signing. This can probably go away very soon in // favour of just signing with the cross-singing master key. // XXX: Private member access - await this.crypto._signObject(data.auth_data); + await this.crypto.signObject(data.auth_data); if ( this.cryptoCallbacks.getCrossSigningKey && // XXX: Private member access - this.crypto._crossSigningInfo.getId() + this.crypto.crossSigningInfo.getId() ) { // now also sign the auth data with the cross-signing master key // we check for the callback explicitly here because we still want to be able // to create an un-cross-signed key backup if there is a cross-signing key but // no callback supplied. // XXX: Private member access - await this.crypto._crossSigningInfo.signObject(data.auth_data, "master"); + await this.crypto.crossSigningInfo.signObject(data.auth_data, "master"); } const res = await this.http.authedRequest( @@ -2271,8 +2274,8 @@ export class MatrixClient extends EventEmitter { // If we're currently backing up to this backup... stop. // (We start using it automatically in createKeyBackupVersion // so this is symmetrical). - if (this.crypto._backupManager.version) { - this.crypto._backupManager.disableKeyBackup(); + if (this.crypto.backupManager.version) { + this.crypto.backupManager.disableKeyBackup(); } const path = utils.encodeUri("/room_keys/version/$version", { @@ -2337,7 +2340,7 @@ export class MatrixClient extends EventEmitter { throw new Error("End-to-end encryption disabled"); } - await this.crypto._backupManager.scheduleAllGroupSessionsForBackup(); + await this.crypto.backupManager.scheduleAllGroupSessionsForBackup(); } /** @@ -2350,7 +2353,7 @@ export class MatrixClient extends EventEmitter { throw new Error("End-to-end encryption disabled"); } - return this.crypto._backupManager.flagAllGroupSessionsForBackup(); + return this.crypto.backupManager.flagAllGroupSessionsForBackup(); } public isValidRecoveryKey(recoveryKey: string): boolean { @@ -2633,7 +2636,7 @@ export class MatrixClient extends EventEmitter { } // XXX: Private member access - const alg = this.crypto._getRoomDecryptor(roomId, roomEncryption.algorithm); + const alg = this.crypto.getRoomDecryptor(roomId, roomEncryption.algorithm); if (alg.sendSharedHistoryInboundSessions) { await alg.sendSharedHistoryInboundSessions(devicesByUser); } else { @@ -5708,7 +5711,7 @@ export class MatrixClient extends EventEmitter { */ public getCrossSigningCacheCallbacks(): any { // TODO: Types // XXX: Private member access - return this.crypto?._crossSigningInfo.getCacheCallbacks(); + return this.crypto?.crossSigningInfo.getCacheCallbacks(); } /** @@ -7087,7 +7090,7 @@ export class MatrixClient extends EventEmitter { * @return {Promise} Resolves: result object. Rejects: with * an error response ({@link module:http-api.MatrixError}). */ - public uploadKeysRequest(content: any, opts?: any, callback?: Callback): Promise { // TODO: Types + public uploadKeysRequest(content: any, opts?: any, callback?: Callback): Promise { return this.http.authedRequest(callback, "POST", "/keys/upload", undefined, content); } diff --git a/src/crypto/api.ts b/src/crypto/api.ts index 24528486e..39469a83a 100644 --- a/src/crypto/api.ts +++ b/src/crypto/api.ts @@ -60,7 +60,7 @@ export interface IEncryptedEventInfo { export interface IRecoveryKey { keyInfo: { - pubkey: Uint8Array; + pubkey: string; passphrase?: { algorithm: string; iterations: number; diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 79b7e9752..f2b62cc01 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -48,11 +48,16 @@ type SigInfo = { deviceTrust?: DeviceTrustLevel, }; -type TrustInfo = { +export type TrustInfo = { usable: boolean, // is the backup trusted, true iff there is a sig that is valid & from a trusted device sigs: SigInfo[], }; +export interface IKeyBackupCheck { + backupInfo: BackupInfo; + trustInfo: TrustInfo; +} + /** A function used to get the secret key for a backup. */ type GetKey = () => Promise; @@ -81,7 +86,7 @@ interface BackupAlgorithm { */ export class BackupManager { private algorithm: BackupAlgorithm | undefined; - private backupInfo: BackupInfo | undefined; // The info dict from /room_keys/version + public 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) { @@ -232,7 +237,7 @@ export class BackupManager { * trust information (as returned by isKeyBackupTrusted) * in trustInfo. */ - public async checkKeyBackup(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> { + public async checkKeyBackup(): Promise { this.checkedForBackup = false; return this.checkAndStart(); } @@ -268,7 +273,7 @@ export class BackupManager { return ret; } - const trustedPubkey = this.baseApis.crypto._sessionStore.getLocalTrustedBackupPubKey(); + const trustedPubkey = this.baseApis.crypto.sessionStore.getLocalTrustedBackupPubKey(); if (backupInfo.auth_data.public_key === trustedPubkey) { logger.info("Backup public key " + trustedPubkey + " is trusted locally"); @@ -288,12 +293,12 @@ export class BackupManager { 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(); + const crossSigningId = this.baseApis.crypto.crossSigningInfo.getId(); if (crossSigningId === sigInfo.deviceId) { sigInfo.crossSigningId = true; try { await verifySignature( - this.baseApis.crypto._olmDevice, + this.baseApis.crypto.olmDevice, backupInfo.auth_data, this.baseApis.getUserId(), sigInfo.deviceId, @@ -313,7 +318,7 @@ export class BackupManager { // 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( + const device = this.baseApis.crypto.deviceList.getStoredDevice( this.baseApis.getUserId(), sigInfo.deviceId, ); if (device) { @@ -323,7 +328,7 @@ export class BackupManager { ); try { await verifySignature( - this.baseApis.crypto._olmDevice, + this.baseApis.crypto.olmDevice, backupInfo.auth_data, this.baseApis.getUserId(), device.deviceId, @@ -423,12 +428,12 @@ export class BackupManager { * @returns {integer} Number of sessions backed up */ private async backupPendingKeys(limit: number): Promise { - const sessions = await this.baseApis.crypto._cryptoStore.getSessionsNeedingBackup(limit); + const sessions = await this.baseApis.crypto.cryptoStore.getSessionsNeedingBackup(limit); if (!sessions.length) { return 0; } - let remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup(); + let remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining); const data = {}; @@ -438,7 +443,7 @@ export class BackupManager { data[roomId] = { sessions: {} }; } - const sessionData = await this.baseApis.crypto._olmDevice.exportInboundGroupSession( + const sessionData = await this.baseApis.crypto.olmDevice.exportInboundGroupSession( session.senderKey, session.sessionId, session.sessionData, ); sessionData.algorithm = MEGOLM_ALGORITHM; @@ -446,13 +451,13 @@ export class BackupManager { const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length; - const userId = this.baseApis.crypto._deviceList.getUserByIdentityKey( + const userId = this.baseApis.crypto.deviceList.getUserByIdentityKey( MEGOLM_ALGORITHM, session.senderKey, ); - const device = this.baseApis.crypto._deviceList.getDeviceByIdentityKey( + const device = this.baseApis.crypto.deviceList.getDeviceByIdentityKey( MEGOLM_ALGORITHM, session.senderKey, ); - const verified = this.baseApis.crypto._checkDeviceInfoTrust(userId, device).isVerified(); + const verified = this.baseApis.crypto.checkDeviceInfoTrust(userId, device).isVerified(); data[roomId]['sessions'][session.sessionId] = { first_message_index: sessionData.first_known_index, @@ -467,8 +472,8 @@ export class BackupManager { { rooms: data }, ); - await this.baseApis.crypto._cryptoStore.unmarkSessionsNeedingBackup(sessions); - remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup(); + await this.baseApis.crypto.cryptoStore.unmarkSessionsNeedingBackup(sessions); + remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining); return sessions.length; @@ -477,7 +482,7 @@ export class BackupManager { public async backupGroupSession( senderKey: string, sessionId: string, ): Promise { - await this.baseApis.crypto._cryptoStore.markSessionsNeedingBackup([{ + await this.baseApis.crypto.cryptoStore.markSessionsNeedingBackup([{ senderKey: senderKey, sessionId: sessionId, }]); @@ -509,22 +514,22 @@ export class BackupManager { * (which will be equal to the number of sessions in the store). */ public async flagAllGroupSessionsForBackup(): Promise { - await this.baseApis.crypto._cryptoStore.doTxn( + await this.baseApis.crypto.cryptoStore.doTxn( 'readwrite', [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP, ], (txn) => { - this.baseApis.crypto._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => { + this.baseApis.crypto.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => { if (session !== null) { - this.baseApis.crypto._cryptoStore.markSessionsNeedingBackup([session], txn); + this.baseApis.crypto.cryptoStore.markSessionsNeedingBackup([session], txn); } }); }, ); - const remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup(); + const remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); this.baseApis.emit("crypto.keyBackupSessionsRemaining", remaining); return remaining; } @@ -534,7 +539,7 @@ export class BackupManager { * @returns {Promise} Resolves to the number of sessions requiring backup */ public countSessionsNeedingBackup(): Promise { - return this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup(); + return this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); } } diff --git a/src/crypto/dehydration.ts b/src/crypto/dehydration.ts index f32daaeb7..eb003f75b 100644 --- a/src/crypto/dehydration.ts +++ b/src/crypto/dehydration.ts @@ -292,7 +292,7 @@ export class DehydrationManager { } } - private stop() { + public stop() { if (this.timeoutId) { global.clearTimeout(this.timeoutId); this.timeoutId = undefined; diff --git a/src/crypto/index.js b/src/crypto/index.js deleted file mode 100644 index a1171b5f5..000000000 --- a/src/crypto/index.js +++ /dev/null @@ -1,3651 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018-2019 New Vector Ltd -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. -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 - */ - -import anotherjson from "another-json"; -import { EventEmitter } from 'events'; -import { ReEmitter } from '../ReEmitter'; -import { logger } from '../logger'; -import * as utils from "../utils"; -import { OlmDevice } from "./OlmDevice"; -import * as olmlib from "./olmlib"; -import { DeviceList } from "./DeviceList"; -import { DeviceInfo } from "./deviceinfo"; -import * as algorithms from "./algorithms"; -import { - CrossSigningInfo, - DeviceTrustLevel, - UserTrustLevel, - createCryptoStoreCacheCallbacks, -} from './CrossSigning'; -import { EncryptionSetupBuilder } from "./EncryptionSetup"; -import { SECRET_STORAGE_ALGORITHM_V1_AES, SecretStorage } from './SecretStorage'; -import { OutgoingRoomKeyRequestManager } from './OutgoingRoomKeyRequestManager'; -import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; -import { - ReciprocateQRCode, - SCAN_QR_CODE_METHOD, - SHOW_QR_CODE_METHOD, -} from './verification/QRCode'; -import { SAS } from './verification/SAS'; -import { keyFromPassphrase } from './key_passphrase'; -import { encodeRecoveryKey, decodeRecoveryKey } from './recoverykey'; -import { VerificationRequest } from "./verification/request/VerificationRequest"; -import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel"; -import { ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel"; -import { IllegalMethod } from "./verification/IllegalMethod"; -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; - -const defaultVerificationMethods = { - [ReciprocateQRCode.NAME]: ReciprocateQRCode, - [SAS.NAME]: SAS, - - // These two can't be used for actual verification, but we do - // need to be able to define them here for the verification flows - // to start. - [SHOW_QR_CODE_METHOD]: IllegalMethod, - [SCAN_QR_CODE_METHOD]: IllegalMethod, -}; - -/** - * verification method names - */ -export const verificationMethods = { - RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME, - SAS: SAS.NAME, -}; - -export function isCryptoAvailable() { - return Boolean(global.Olm); -} - -const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; - -/** - * Cryptography bits - * - * This module is internal to the js-sdk; the public API is via MatrixClient. - * - * @constructor - * @alias module:crypto - * - * @internal - * - * @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 - * - * @param {string} userId The user ID for the local user - * - * @param {string} deviceId The identifier for this device. - * - * @param {Object} clientStore the MatrixClient data store. - * - * @param {module:crypto/store/base~CryptoStore} cryptoStore - * storage for the crypto layer. - * - * @param {RoomList} roomList An initialised RoomList object - * - * @param {Array} verificationMethods Array of verification methods to use. - * Each element can either be a string from MatrixClient.verificationMethods - * or a class that implements a verification method. - */ -export function Crypto(baseApis, sessionStore, userId, deviceId, - clientStore, cryptoStore, roomList, verificationMethods) { - this._onDeviceListUserCrossSigningUpdated = - this._onDeviceListUserCrossSigningUpdated.bind(this); - - this._trustCrossSignedDevices = true; - - this._reEmitter = new ReEmitter(this); - this._baseApis = baseApis; - this._sessionStore = sessionStore; - this._userId = userId; - this._deviceId = deviceId; - this._clientStore = clientStore; - this._cryptoStore = cryptoStore; - this._roomList = roomList; - if (verificationMethods) { - this._verificationMethods = new Map(); - for (const method of verificationMethods) { - if (typeof method === "string") { - if (defaultVerificationMethods[method]) { - this._verificationMethods.set( - method, - defaultVerificationMethods[method], - ); - } - } else if (method.NAME) { - this._verificationMethods.set( - method.NAME, - method, - ); - } else { - logger.warn(`Excluding unknown verification method ${method}`); - } - } - } else { - this._verificationMethods = defaultVerificationMethods; - } - - 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( - baseApis, cryptoStore, this._olmDevice, - ); - // XXX: This isn't removed at any point, but then none of the event listeners - // this class sets seem to be removed at any point... :/ - this._deviceList.on( - 'userCrossSigningUpdated', this._onDeviceListUserCrossSigningUpdated, - ); - this._reEmitter.reEmit(this._deviceList, [ - "crypto.devicesUpdated", "crypto.willUpdateDevices", - ]); - - // the last time we did a check for the number of one-time-keys on the - // server. - this._lastOneTimeKeyCheck = null; - this._oneTimeKeyCheckInProgress = false; - - // EncryptionAlgorithm instance for each room - this._roomEncryptors = {}; - - // map from algorithm to DecryptionAlgorithm instance, for each room - this._roomDecryptors = {}; - - this._supportedAlgorithms = Object.keys( - algorithms.DECRYPTION_CLASSES, - ); - - this._deviceKeys = {}; - - this._globalBlacklistUnverifiedDevices = false; - this._globalErrorOnUnknownDevices = true; - - this._outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager( - baseApis, this._deviceId, this._cryptoStore, - ); - - // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations - // we received in the current sync. - this._receivedRoomKeyRequests = []; - this._receivedRoomKeyRequestCancellations = []; - // true if we are currently processing received room key requests - this._processingRoomKeyRequests = false; - // controls whether device tracking is delayed - // until calling encryptEvent or trackRoomDevices, - // or done immediately upon enabling room encryption. - this._lazyLoadMembers = false; - // in case _lazyLoadMembers is true, - // track if an initial tracking of all the room members - // has happened for a given room. This is delayed - // to avoid loading room members as long as possible. - this._roomDeviceTrackingState = {}; - - // The timestamp of the last time we forced establishment - // of a new session for each device, in milliseconds. - // { - // userId: { - // deviceId: 1234567890000, - // }, - // } - this._lastNewSessionForced = {}; - - this._toDeviceVerificationRequests = new ToDeviceRequests(); - this._inRoomVerificationRequests = new InRoomRequests(); - - // This flag will be unset whilst the client processes a sync response - // so that we don't start requesting keys until we've actually finished - // processing the response. - this._sendKeyRequestsImmediately = false; - - const cryptoCallbacks = this._baseApis.cryptoCallbacks || {}; - const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this._olmDevice); - - this._crossSigningInfo = new CrossSigningInfo( - userId, - cryptoCallbacks, - cacheCallbacks, - ); - - this._secretStorage = new SecretStorage( - baseApis, cryptoCallbacks, - ); - - this._dehydrationManager = new DehydrationManager(this); - - // Assuming no app-supplied callback, default to getting from SSSS. - if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) { - cryptoCallbacks.getCrossSigningKey = async (type) => { - return CrossSigningInfo.getFromSecretStorage(type, this._secretStorage); - }; - } -} -utils.inherits(Crypto, EventEmitter); - -/** - * Initialise the crypto module so that it is ready for use - * - * Returns a promise which resolves once the crypto module is ready for use. - * - * @param {Object} opts keyword arguments. - * @param {string} opts.exportedOlmDevice (Optional) data from exported device - * that must be re-created. - */ -Crypto.prototype.init = async function(opts) { - const { - exportedOlmDevice, - pickleKey, - } = opts || {}; - - logger.log("Crypto: initialising Olm..."); - await global.Olm.init(); - logger.log( - exportedOlmDevice - ? "Crypto: initialising Olm device from exported device..." - : "Crypto: initialising Olm device...", - ); - await this._olmDevice.init({ fromExportedDevice: exportedOlmDevice, pickleKey }); - logger.log("Crypto: loading device list..."); - await this._deviceList.load(); - - // build our device keys: these will later be uploaded - this._deviceKeys["ed25519:" + this._deviceId] = - this._olmDevice.deviceEd25519Key; - this._deviceKeys["curve25519:" + this._deviceId] = - this._olmDevice.deviceCurve25519Key; - - logger.log("Crypto: fetching own devices..."); - let myDevices = this._deviceList.getRawStoredDevicesForUser( - this._userId, - ); - - if (!myDevices) { - myDevices = {}; - } - - if (!myDevices[this._deviceId]) { - // add our own deviceinfo to the cryptoStore - logger.log("Crypto: adding this device to the store..."); - const deviceInfo = { - keys: this._deviceKeys, - algorithms: this._supportedAlgorithms, - verified: DeviceVerification.VERIFIED, - known: true, - }; - - myDevices[this._deviceId] = deviceInfo; - this._deviceList.storeDevicesForUser( - this._userId, myDevices, - ); - this._deviceList.saveIfDirty(); - } - - await this._cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this._cryptoStore.getCrossSigningKeys(txn, (keys) => { - // can be an empty object after resetting cross-signing keys, see _storeTrustedSelfKeys - if (keys && Object.keys(keys).length !== 0) { - logger.log("Loaded cross-signing public keys from crypto store"); - this._crossSigningInfo.setKeys(keys); - } - }); - }, - ); - // make sure we are keeping track of our own devices - // (this is important for key backups & things) - this._deviceList.startTrackingDeviceList(this._userId); - - logger.log("Crypto: checking for key backup..."); - this._backupManager.checkAndStart(); -}; - -/** - * Whether to trust a others users signatures of their devices. - * If false, devices will only be considered 'verified' if we have - * verified that device individually (effectively disabling cross-signing). - * - * Default: true - * - * @return {bool} True if trusting cross-signed devices - */ -Crypto.prototype.getCryptoTrustCrossSignedDevices = function() { - return this._trustCrossSignedDevices; -}; - -/** - * See getCryptoTrustCrossSignedDevices - - * This may be set before initCrypto() is called to ensure no races occur. - * - * @param {bool} val True to trust cross-signed devices - */ -Crypto.prototype.setCryptoTrustCrossSignedDevices = function(val) { - this._trustCrossSignedDevices = val; - - for (const userId of this._deviceList.getKnownUserIds()) { - const devices = this._deviceList.getRawStoredDevicesForUser(userId); - for (const deviceId of Object.keys(devices)) { - const deviceTrust = this.checkDeviceTrust(userId, deviceId); - // If the device is locally verified then isVerified() is always true, - // so this will only have caused the value to change if the device is - // cross-signing verified but not locally verified - if ( - !deviceTrust.isLocallyVerified() && - deviceTrust.isCrossSigningVerified() - ) { - const deviceObj = this._deviceList.getStoredDevice(userId, deviceId); - this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); - } - } - } -}; - -/** - * Create a recovery key from a user-supplied passphrase. - * - * @param {string} password Passphrase string that can be entered by the user - * when restoring the backup as an alternative to entering the recovery key. - * Optional. - * @returns {Promise} 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. - */ -Crypto.prototype.createRecoveryKeyFromPassphrase = async function(password) { - const decryption = new global.Olm.PkDecryption(); - try { - const keyInfo = {}; - if (password) { - const derivation = await keyFromPassphrase(password); - keyInfo.passphrase = { - algorithm: "m.pbkdf2", - iterations: derivation.iterations, - salt: derivation.salt, - }; - keyInfo.pubkey = decryption.init_with_private_key(derivation.key); - } else { - keyInfo.pubkey = decryption.generate_key(); - } - const privateKey = decryption.get_private_key(); - const encodedPrivateKey = encodeRecoveryKey(privateKey); - return { keyInfo, encodedPrivateKey, privateKey }; - } finally { - if (decryption) decryption.free(); - } -}; - -/** - * Checks whether cross signing: - * - is enabled on this account and trusted by this device - * - has private keys either cached locally or stored in secret storage - * - * If this function returns false, bootstrapCrossSigning() can be used - * to fix things such that it returns true. That is to say, after - * bootstrapCrossSigning() completes successfully, this function should - * return true. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @return {bool} True if cross-signing is ready to be used on this device - */ -Crypto.prototype.isCrossSigningReady = async function() { - const publicKeysOnDevice = this._crossSigningInfo.getId(); - const privateKeysExistSomewhere = ( - await this._crossSigningInfo.isStoredInKeyCache() || - await this._crossSigningInfo.isStoredInSecretStorage( - this._secretStorage, - ) - ); - - return !!( - publicKeysOnDevice && - privateKeysExistSomewhere - ); -}; - -/** - * Checks whether secret storage: - * - is enabled on this account - * - is storing cross-signing private keys - * - is storing session backup key (if enabled) - * - * If this function returns false, bootstrapSecretStorage() can be used - * to fix things such that it returns true. That is to say, after - * bootstrapSecretStorage() completes successfully, this function should - * return true. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @return {bool} True if secret storage is ready to be used on this device - */ -Crypto.prototype.isSecretStorageReady = async function() { - const secretStorageKeyInAccount = await this._secretStorage.hasKey(); - const privateKeysInStorage = await this._crossSigningInfo.isStoredInSecretStorage( - this._secretStorage, - ); - const sessionBackupInStorage = ( - !this._backupManager.getKeyBackupEnabled() || - this._baseApis.isKeyBackupKeyStored() - ); - - return !!( - secretStorageKeyInAccount && - privateKeysInStorage && - sessionBackupInStorage - ); -}; - -/** - * Bootstrap cross-signing by creating keys if needed. If everything is already - * set up, then no changes are made, so this is safe to run to ensure - * cross-signing is ready for use. - * - * This function: - * - creates new cross-signing keys if they are not found locally cached nor in - * secret storage (if it has been setup) - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param {function} opts.authUploadDeviceSigningKeys Function - * called to await an interactive auth flow when uploading device signing keys. - * @param {bool} [opts.setupNewCrossSigning] Optional. Reset even if keys - * already exist. - * Args: - * {function} A function that makes the request requiring auth. Receives the - * auth data as an object. Can be called multiple times, first with an empty - * authDict, to obtain the flows. - */ -Crypto.prototype.bootstrapCrossSigning = async function({ - authUploadDeviceSigningKeys, - setupNewCrossSigning, -} = {}) { - logger.log("Bootstrapping cross-signing"); - - const delegateCryptoCallbacks = this._baseApis.cryptoCallbacks; - const builder = new EncryptionSetupBuilder( - this._baseApis.store.accountData, - delegateCryptoCallbacks, - ); - const crossSigningInfo = new CrossSigningInfo( - this._userId, - builder.crossSigningCallbacks, - builder.crossSigningCallbacks, - ); - - // Reset the cross-signing keys - const resetCrossSigning = async () => { - crossSigningInfo.resetKeys(); - // Sign master key with device key - await this._signObject(crossSigningInfo.keys.master); - - // Store auth flow helper function, as we need to call it when uploading - // to ensure we handle auth errors properly. - builder.addCrossSigningKeys(authUploadDeviceSigningKeys, crossSigningInfo.keys); - - // Cross-sign own device - const device = this._deviceList.getStoredDevice(this._userId, this._deviceId); - const deviceSignature = await crossSigningInfo.signDevice(this._userId, device); - builder.addKeySignature(this._userId, this._deviceId, deviceSignature); - - // Sign message key backup with cross-signing master key - if (this._backupManager.backupInfo) { - await crossSigningInfo.signObject( - this._backupManager.backupInfo.auth_data, "master", - ); - builder.addSessionBackup(this._backupManager.backupInfo); - } - }; - - const publicKeysOnDevice = this._crossSigningInfo.getId(); - const privateKeysInCache = await this._crossSigningInfo.isStoredInKeyCache(); - const privateKeysInStorage = await this._crossSigningInfo.isStoredInSecretStorage( - this._secretStorage, - ); - const privateKeysExistSomewhere = ( - privateKeysInCache || - privateKeysInStorage - ); - - // Log all relevant state for easier parsing of debug logs. - logger.log({ - setupNewCrossSigning, - publicKeysOnDevice, - privateKeysInCache, - privateKeysInStorage, - privateKeysExistSomewhere, - }); - - if (!privateKeysExistSomewhere || setupNewCrossSigning) { - logger.log( - "Cross-signing private keys not found locally or in secret storage, " + - "creating new keys", - ); - // If a user has multiple devices, it important to only call bootstrap - // as part of some UI flow (and not silently during startup), as they - // may have setup cross-signing on a platform which has not saved keys - // to secret storage, and this would reset them. In such a case, you - // should prompt the user to verify any existing devices first (and - // request private keys from those devices) before calling bootstrap. - await resetCrossSigning(); - } else if (publicKeysOnDevice && privateKeysInCache) { - logger.log( - "Cross-signing public keys trusted and private keys found locally", - ); - } else if (privateKeysInStorage) { - logger.log( - "Cross-signing private keys not found locally, but they are available " + - "in secret storage, reading storage and caching locally", - ); - await this.checkOwnCrossSigningTrust({ - allowPrivateKeyRequests: true, - }); - } - - // Assuming no app-supplied callback, default to storing new private keys in - // secret storage if it exists. If it does not, it is assumed this will be - // done as part of setting up secret storage later. - const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys; - if ( - crossSigningPrivateKeys.size && - !this._baseApis.cryptoCallbacks.saveCrossSigningKeys - ) { - const secretStorage = new SecretStorage( - builder.accountDataClientAdapter, - builder.ssssCryptoCallbacks); - if (await secretStorage.hasKey()) { - logger.log("Storing new cross-signing private keys in secret storage"); - // This is writing to in-memory account data in - // builder.accountDataClientAdapter so won't fail - await CrossSigningInfo.storeInSecretStorage( - crossSigningPrivateKeys, - secretStorage, - ); - } - } - - const operation = builder.buildOperation(); - await operation.apply(this); - // This persists private keys and public keys as trusted, - // only do this if apply succeeded for now as retry isn't in place yet - await builder.persist(this); - - logger.log("Cross-signing ready"); -}; - -/** - * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is - * already set up, then no changes are made, so this is safe to run to ensure secret - * storage is ready for use. - * - * This function - * - creates a new Secure Secret Storage key if no default key exists - * - if a key backup exists, it is migrated to store the key in the Secret - * Storage - * - creates a backup if none exists, and one is requested - * - migrates Secure Secret Storage to use the latest algorithm, if an outdated - * algorithm is found - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param {function} [opts.createSecretStorageKey] Optional. Function - * called to await a secret storage key creation flow. - * Returns: - * {Promise} 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. - * @param {object} [opts.keyBackupInfo] The current key backup object. If passed, - * the passphrase and recovery key from this backup will be used. - * @param {bool} [opts.setupNewKeyBackup] 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. - * @param {bool} [opts.setupNewSecretStorage] Optional. Reset even if keys already exist. - * @param {func} [opts.getKeyBackupPassphrase] Optional. Function called to get the user's - * current key backup passphrase. Should return a promise that resolves with a Buffer - * containing the key, or rejects if the key cannot be obtained. - * Returns: - * {Promise} A promise which resolves to key creation data for - * SecretStorage#addKey: an object with `passphrase` etc fields. - */ -Crypto.prototype.bootstrapSecretStorage = async function({ - createSecretStorageKey = async () => ({ }), - keyBackupInfo, - setupNewKeyBackup, - setupNewSecretStorage, - getKeyBackupPassphrase, -} = {}) { - logger.log("Bootstrapping Secure Secret Storage"); - const delegateCryptoCallbacks = this._baseApis.cryptoCallbacks; - const builder = new EncryptionSetupBuilder( - this._baseApis.store.accountData, - delegateCryptoCallbacks, - ); - const secretStorage = new SecretStorage( - builder.accountDataClientAdapter, - builder.ssssCryptoCallbacks, - ); - - // the ID of the new SSSS key, if we create one - let newKeyId = null; - - // create a new SSSS key and set it as default - const createSSSS = async (opts, privateKey) => { - opts = opts || {}; - if (privateKey) { - opts.key = privateKey; - } - - const { keyId, keyInfo } = await secretStorage.addKey( - SECRET_STORAGE_ALGORITHM_V1_AES, opts, - ); - - if (privateKey) { - // make the private key available to encrypt 4S secrets - builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); - } - - await secretStorage.setDefaultKeyId(keyId); - return keyId; - }; - - const ensureCanCheckPassphrase = async (keyId, keyInfo) => { - if (!keyInfo.mac) { - const key = await this._baseApis.cryptoCallbacks.getSecretStorageKey( - { keys: { [keyId]: keyInfo } }, "", - ); - if (key) { - const privateKey = key[1]; - builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); - const { iv, mac } = await SecretStorage._calculateKeyCheck(privateKey); - keyInfo.iv = iv; - keyInfo.mac = mac; - - await builder.setAccountData( - `m.secret_storage.key.${keyId}`, keyInfo, - ); - } - } - }; - - const signKeyBackupWithCrossSigning = async (keyBackupAuthData) => { - if ( - this._crossSigningInfo.getId() && - await this._crossSigningInfo.isStoredInKeyCache("master") - ) { - try { - logger.log("Adding cross-signing signature to key backup"); - await this._crossSigningInfo.signObject(keyBackupAuthData, "master"); - } catch (e) { - // This step is not critical (just helpful), so we catch here - // and continue if it fails. - logger.error("Signing key backup with cross-signing keys failed", e); - } - } else { - logger.warn( - "Cross-signing keys not available, skipping signature on key backup", - ); - } - }; - - const oldSSSSKey = await this.getSecretStorageKey(); - const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null]; - const storageExists = ( - !setupNewSecretStorage && - oldKeyInfo && - oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES - ); - - // Log all relevant state for easier parsing of debug logs. - logger.log({ - keyBackupInfo, - setupNewKeyBackup, - setupNewSecretStorage, - storageExists, - oldKeyInfo, - }); - - if (!storageExists && !keyBackupInfo) { - // either we don't have anything, or we've been asked to restart - // from scratch - logger.log( - "Secret storage does not exist, creating new storage key", - ); - - // if we already have a usable default SSSS key and aren't resetting - // SSSS just use it. otherwise, create a new one - // Note: we leave the old SSSS key in place: there could be other - // secrets using it, in theory. We could move them to the new key but a) - // that would mean we'd need to prompt for the old passphrase, and b) - // it's not clear that would be the right thing to do anyway. - const { keyInfo, privateKey } = await createSecretStorageKey(); - newKeyId = await createSSSS(keyInfo, privateKey); - } else if (!storageExists && keyBackupInfo) { - // we have an existing backup, but no SSSS - logger.log("Secret storage does not exist, using key backup key"); - - // if we have the backup key already cached, use it; otherwise use the - // callback to prompt for the key - const backupKey = await this.getSessionBackupPrivateKey() || - await getKeyBackupPassphrase(); - - // create a new SSSS key and use the backup key as the new SSSS key - const opts = {}; - - if ( - 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, - salt: keyBackupInfo.auth_data.private_key_salt, - bits: 256, - }; - } - - newKeyId = await createSSSS(opts, backupKey); - - // store the backup key in secret storage - await secretStorage.store( - "m.megolm_backup.v1", olmlib.encodeBase64(backupKey), [newKeyId], - ); - - // 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 signKeyBackupWithCrossSigning(keyBackupInfo.auth_data); - - builder.addSessionBackup(keyBackupInfo); - } else { - // 4S is already set up - logger.log("Secret storage exists"); - - if (oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { - // make sure that the default key has the information needed to - // check the passphrase - await ensureCanCheckPassphrase(oldKeyId, oldKeyInfo); - } - } - - // If we have cross-signing private keys cached, store them in secret - // storage if they are not there already. - if ( - !this._baseApis.cryptoCallbacks.saveCrossSigningKeys && - await this.isCrossSigningReady() && - (newKeyId || !await this._crossSigningInfo.isStoredInSecretStorage(secretStorage)) - ) { - logger.log("Copying cross-signing private keys from cache to secret storage"); - const crossSigningPrivateKeys = - await this._crossSigningInfo.getCrossSigningKeysFromCache(); - // This is writing to in-memory account data in - // builder.accountDataClientAdapter so won't fail - await CrossSigningInfo.storeInSecretStorage( - crossSigningPrivateKeys, - secretStorage, - ); - } - - if (setupNewKeyBackup && !keyBackupInfo) { - logger.log("Creating new message key backup version"); - const info = await this._baseApis.prepareKeyBackupVersion( - null /* random key */, - // don't write to secret storage, as it will write to this._secretStorage. - // Here, we want to capture all the side-effects of bootstrapping, - // and want to write to the local secretStorage object - { secureSecretStorage: false }, - ); - // write the key ourselves to 4S - const privateKey = decodeRecoveryKey(info.recovery_key); - await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey)); - - // create keyBackupInfo object to add to builder - const data = { - algorithm: info.algorithm, - auth_data: info.auth_data, - }; - - // Sign with cross-signing master key - await signKeyBackupWithCrossSigning(data.auth_data); - - // sign with the device fingerprint - await this._signObject(data.auth_data); - - builder.addSessionBackup(data); - } - - // Cache the session backup key - const sessionBackupKey = await secretStorage.get('m.megolm_backup.v1'); - if (sessionBackupKey) { - logger.info("Got session backup key from secret storage: caching"); - // fix up the backup key if it's in the wrong format, and replace - // in secret storage - const fixedBackupKey = fixBackupKey(sessionBackupKey); - if (fixedBackupKey) { - await secretStorage.store("m.megolm_backup.v1", - fixedBackupKey, [newKeyId || oldKeyId], - ); - } - const decodedBackupKey = new Uint8Array(olmlib.decodeBase64( - fixedBackupKey || sessionBackupKey, - )); - await builder.addSessionBackupPrivateKeyToCache(decodedBackupKey); - } - - const operation = builder.buildOperation(); - await operation.apply(this); - // this persists private keys and public keys as trusted, - // only do this if apply succeeded for now as retry isn't in place yet - await builder.persist(this); - - logger.log("Secure Secret Storage ready"); -}; - -/** - * Fix up the backup key, that may be in the wrong format due to a bug in a - * migration step. Some backup keys were stored as a comma-separated list of - * integers, rather than a base64-encoded byte array. If this function is - * passed a string that looks like a list of integers rather than a base64 - * string, it will attempt to convert it to the right format. - * - * @param {string} key the key to check - * @returns {null | string} If the key is in the wrong format, then the fixed - * key will be returned. Otherwise null will be returned. - * - */ -export function fixBackupKey(key) { - if (typeof key !== "string" || key.indexOf(",") < 0) { - return null; - } - const fixedKey = Uint8Array.from(key.split(","), x => parseInt(x)); - return olmlib.encodeBase64(fixedKey); -} - -Crypto.prototype.addSecretStorageKey = function(algorithm, opts, keyID) { - return this._secretStorage.addKey(algorithm, opts, keyID); -}; - -Crypto.prototype.hasSecretStorageKey = function(keyID) { - return this._secretStorage.hasKey(keyID); -}; - -Crypto.prototype.getSecretStorageKey = function(keyID) { - return this._secretStorage.getKey(keyID); -}; - -Crypto.prototype.storeSecret = function(name, secret, keys) { - return this._secretStorage.store(name, secret, keys); -}; - -Crypto.prototype.getSecret = function(name) { - return this._secretStorage.get(name); -}; - -Crypto.prototype.isSecretStored = function(name, checkKey) { - return this._secretStorage.isStored(name, checkKey); -}; - -Crypto.prototype.requestSecret = function(name, devices) { - if (!devices) { - devices = Object.keys(this._deviceList.getRawStoredDevicesForUser(this._userId)); - } - return this._secretStorage.request(name, devices); -}; - -Crypto.prototype.getDefaultSecretStorageKeyId = function() { - return this._secretStorage.getDefaultKeyId(); -}; - -Crypto.prototype.setDefaultSecretStorageKeyId = function(k) { - return this._secretStorage.setDefaultKeyId(k); -}; - -Crypto.prototype.checkSecretStorageKey = function(key, info) { - return this._secretStorage.checkKey(key, info); -}; - -/** - * Checks that a given secret storage private key matches a given public key. - * This can be used by the getSecretStorageKey callback to verify that the - * private key it is about to supply is the one that was requested. - * - * @param {Uint8Array} privateKey The private key - * @param {string} expectedPublicKey The public key - * @returns {boolean} true if the key matches, otherwise false - */ -Crypto.prototype.checkSecretStoragePrivateKey = function(privateKey, expectedPublicKey) { - let decryption = null; - try { - decryption = new global.Olm.PkDecryption(); - const gotPubkey = decryption.init_with_private_key(privateKey); - // make sure it agrees with the given pubkey - return gotPubkey === expectedPublicKey; - } finally { - if (decryption) decryption.free(); - } -}; - -/** - * Fetches the backup private key, if cached - * @returns {Promise} the key, if any, or null - */ -Crypto.prototype.getSessionBackupPrivateKey = async function() { - let key = await new Promise((resolve) => { - this._cryptoStore.doTxn( - 'readonly', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this._cryptoStore.getSecretStorePrivateKey( - txn, - resolve, - "m.megolm_backup.v1", - ); - }, - ); - }); - - // make sure we have a Uint8Array, rather than a string - if (key && typeof key === "string") { - key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key)); - await this.storeSessionBackupPrivateKey(key); - } - if (key && key.ciphertext) { - const pickleKey = Buffer.from(this._olmDevice._pickleKey); - const decrypted = await decryptAES(key, pickleKey, "m.megolm_backup.v1"); - key = olmlib.decodeBase64(decrypted); - } - return key; -}; - -/** - * Stores the session backup key to the cache - * @param {Uint8Array} key the private key - * @returns {Promise} so you can catch failures - */ -Crypto.prototype.storeSessionBackupPrivateKey = async function(key) { - if (!(key instanceof Uint8Array)) { - throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`); - } - const pickleKey = Buffer.from(this._olmDevice._pickleKey); - key = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); - return this._cryptoStore.doTxn( - 'readwrite', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this._cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", key); - }, - ); -}; - -/** - * Checks that a given cross-signing private key matches a given public key. - * This can be used by the getCrossSigningKey callback to verify that the - * private key it is about to supply is the one that was requested. - * - * @param {Uint8Array} privateKey The private key - * @param {string} expectedPublicKey The public key - * @returns {boolean} true if the key matches, otherwise false - */ -Crypto.prototype.checkCrossSigningPrivateKey = function(privateKey, expectedPublicKey) { - let signing = null; - try { - signing = new global.Olm.PkSigning(); - const gotPubkey = signing.init_with_seed(privateKey); - // make sure it agrees with the given pubkey - return gotPubkey === expectedPublicKey; - } finally { - if (signing) signing.free(); - } -}; - -/** - * Run various follow-up actions after cross-signing keys have changed locally - * (either by resetting the keys for the account or by getting them from secret - * storage), such as signing the current device, upgrading device - * verifications, etc. - */ -Crypto.prototype._afterCrossSigningLocalKeyChange = async function() { - logger.info("Starting cross-signing key change post-processing"); - - // sign the current device with the new key, and upload to the server - const device = this._deviceList.getStoredDevice(this._userId, this._deviceId); - const signedDevice = await this._crossSigningInfo.signDevice(this._userId, device); - logger.info(`Starting background key sig upload for ${this._deviceId}`); - - const upload = ({ shouldEmit }) => { - return this._baseApis.uploadKeySignatures({ - [this._userId]: { - [this._deviceId]: signedDevice, - }, - }).then((response) => { - const { failures } = response || {}; - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this._baseApis.emit( - "crypto.keySignatureUploadFailure", - failures, - "_afterCrossSigningLocalKeyChange", - upload, // continuation - ); - } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - logger.info(`Finished background key sig upload for ${this._deviceId}`); - }).catch(e => { - logger.error( - `Error during background key sig upload for ${this._deviceId}`, - e, - ); - }); - }; - upload({ shouldEmit: true }); - - const shouldUpgradeCb = ( - this._baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications - ); - if (shouldUpgradeCb) { - logger.info("Starting device verification upgrade"); - - // Check all users for signatures if upgrade callback present - // FIXME: do this in batches - const users = {}; - for (const [userId, crossSigningInfo] - of Object.entries(this._deviceList._crossSigningInfo)) { - const upgradeInfo = await this._checkForDeviceVerificationUpgrade( - userId, CrossSigningInfo.fromStorage(crossSigningInfo, userId), - ); - if (upgradeInfo) { - users[userId] = upgradeInfo; - } - } - - if (Object.keys(users).length > 0) { - logger.info(`Found ${Object.keys(users).length} verif users to upgrade`); - try { - const usersToUpgrade = await shouldUpgradeCb({ users: users }); - if (usersToUpgrade) { - for (const userId of usersToUpgrade) { - if (userId in users) { - await this._baseApis.setDeviceVerified( - userId, users[userId].crossSigningInfo.getId(), - ); - } - } - } - } catch (e) { - logger.log( - "shouldUpgradeDeviceVerifications threw an error: not upgrading", e, - ); - } - } - - logger.info("Finished device verification upgrade"); - } - - logger.info("Finished cross-signing key change post-processing"); -}; - -/** - * Check if a user's cross-signing key is a candidate for upgrading from device - * verification. - * - * @param {string} userId the user whose cross-signing information is to be checked - * @param {object} crossSigningInfo the cross-signing information to check - */ -Crypto.prototype._checkForDeviceVerificationUpgrade = async function( - userId, crossSigningInfo, -) { - // only upgrade if this is the first cross-signing key that we've seen for - // them, and if their cross-signing key isn't already verified - const trustLevel = this._crossSigningInfo.checkUserTrust(crossSigningInfo); - if (crossSigningInfo.firstUse && !trustLevel.verified) { - const devices = this._deviceList.getRawStoredDevicesForUser(userId); - const deviceIds = await this._checkForValidDeviceSignature( - userId, crossSigningInfo.keys.master, devices, - ); - if (deviceIds.length) { - return { - devices: deviceIds.map( - deviceId => DeviceInfo.fromStorage(devices[deviceId], deviceId), - ), - crossSigningInfo, - }; - } - } -}; - -/** - * Check if the cross-signing key is signed by a verified device. - * - * @param {string} userId the user ID whose key is being checked - * @param {object} key the key that is being checked - * @param {object} devices the user's devices. Should be a map from device ID - * to device info - */ -Crypto.prototype._checkForValidDeviceSignature = async function(userId, key, devices) { - const deviceIds = []; - if (devices && key.signatures && key.signatures[userId]) { - for (const signame of Object.keys(key.signatures[userId])) { - const [, deviceId] = signame.split(':', 2); - if (deviceId in devices - && devices[deviceId].verified === DeviceVerification.VERIFIED) { - try { - await olmlib.verifySignature( - this._olmDevice, - key, - userId, - deviceId, - devices[deviceId].keys[signame], - ); - deviceIds.push(deviceId); - } catch (e) {} - } - } - } - return deviceIds; -}; - -/** - * Get the user's cross-signing key ID. - * - * @param {string} [type=master] The type of key to get the ID of. One of - * "master", "self_signing", or "user_signing". Defaults to "master". - * - * @returns {string} the key ID - */ -Crypto.prototype.getCrossSigningId = function(type) { - return this._crossSigningInfo.getId(type); -}; - -/** - * Get the cross signing information for a given user. - * - * @param {string} userId the user ID to get the cross-signing info for. - * - * @returns {CrossSigningInfo} the cross signing informmation for the user. - */ -Crypto.prototype.getStoredCrossSigningForUser = function(userId) { - return this._deviceList.getStoredCrossSigningForUser(userId); -}; - -/** - * Check whether a given user is trusted. - * - * @param {string} userId The ID of the user to check. - * - * @returns {UserTrustLevel} - */ -Crypto.prototype.checkUserTrust = function(userId) { - const userCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); - if (!userCrossSigning) { - return new UserTrustLevel(false, false, false); - } - return this._crossSigningInfo.checkUserTrust(userCrossSigning); -}; - -/** - * Check whether a given device is trusted. - * - * @param {string} userId The ID of the user whose devices is to be checked. - * @param {string} deviceId The ID of the device to check - * - * @returns {DeviceTrustLevel} - */ -Crypto.prototype.checkDeviceTrust = function(userId, deviceId) { - const device = this._deviceList.getStoredDevice(userId, deviceId); - return this._checkDeviceInfoTrust(userId, device); -}; - -/** - * Check whether a given deviceinfo is trusted. - * - * @param {string} userId The ID of the user whose devices is to be checked. - * @param {module:crypto/deviceinfo?} device The device info object to check - * - * @returns {DeviceTrustLevel} - */ -Crypto.prototype._checkDeviceInfoTrust = function(userId, device) { - const trustedLocally = !!(device && device.isVerified()); - - const userCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); - if (device && userCrossSigning) { - // The _trustCrossSignedDevices only affects trust of other people's cross-signing - // signatures - const trustCrossSig = this._trustCrossSignedDevices || userId === this._userId; - return this._crossSigningInfo.checkDeviceTrust( - userCrossSigning, device, trustedLocally, trustCrossSig, - ); - } else { - return new DeviceTrustLevel(false, false, trustedLocally, false); - } -}; - -/* - * Event handler for DeviceList's userNewDevices event - */ -Crypto.prototype._onDeviceListUserCrossSigningUpdated = async function(userId) { - if (userId === this._userId) { - // An update to our own cross-signing key. - // Get the new key first: - const newCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); - const seenPubkey = newCrossSigning ? newCrossSigning.getId() : null; - const currentPubkey = this._crossSigningInfo.getId(); - const changed = currentPubkey !== seenPubkey; - - if (currentPubkey && seenPubkey && !changed) { - // If it's not changed, just make sure everything is up to date - await this.checkOwnCrossSigningTrust(); - } else { - // We'll now be in a state where cross-signing on the account is not trusted - // because our locally stored cross-signing keys will not match the ones - // on the server for our account. So we clear our own stored cross-signing keys, - // effectively disabling cross-signing until the user gets verified by the device - // that reset the keys - this._storeTrustedSelfKeys(null); - // emit cross-signing has been disabled - this.emit("crossSigning.keysChanged", {}); - // as the trust for our own user has changed, - // also emit an event for this - this.emit("userTrustStatusChanged", - this._userId, this.checkUserTrust(userId)); - } - } else { - await this._checkDeviceVerifications(userId); - - // Update verified before latch using the current state and save the new - // latch value in the device list store. - const crossSigning = this._deviceList.getStoredCrossSigningForUser(userId); - if (crossSigning) { - crossSigning.updateCrossSigningVerifiedBefore( - this.checkUserTrust(userId).isCrossSigningVerified(), - ); - this._deviceList.setRawStoredCrossSigningForUser( - userId, crossSigning.toStorage(), - ); - } - - this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); - } -}; - -/** - * Check the copy of our cross-signing key that we have in the device list and - * see if we can get the private key. If so, mark it as trusted. - */ -Crypto.prototype.checkOwnCrossSigningTrust = async function({ - allowPrivateKeyRequests = false, -} = {}) { - const userId = this._userId; - - // Before proceeding, ensure our cross-signing public keys have been - // downloaded via the device list. - await this.downloadKeys([this._userId]); - - // Also check which private keys are locally cached. - const crossSigningPrivateKeys = - await this._crossSigningInfo.getCrossSigningKeysFromCache(); - - // If we see an update to our own master key, check it against the master - // key we have and, if it matches, mark it as verified - - // First, get the new cross-signing info - const newCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); - if (!newCrossSigning) { - logger.error( - "Got cross-signing update event for user " + userId + - " but no new cross-signing information found!", - ); - return; - } - - const seenPubkey = newCrossSigning.getId(); - const masterChanged = this._crossSigningInfo.getId() !== seenPubkey; - const masterExistsNotLocallyCached = - newCrossSigning.getId() && !crossSigningPrivateKeys.has("master"); - if (masterChanged) { - logger.info("Got new master public key", seenPubkey); - } - if ( - allowPrivateKeyRequests && - (masterChanged || masterExistsNotLocallyCached) - ) { - logger.info("Attempting to retrieve cross-signing master private key"); - let signing = null; - // It's important for control flow that we leave any errors alone for - // higher levels to handle so that e.g. cancelling access properly - // aborts any larger operation as well. - try { - const ret = await this._crossSigningInfo.getCrossSigningKey( - 'master', seenPubkey, - ); - signing = ret[1]; - logger.info("Got cross-signing master private key"); - } finally { - if (signing) signing.free(); - } - } - - const oldSelfSigningId = this._crossSigningInfo.getId("self_signing"); - const oldUserSigningId = this._crossSigningInfo.getId("user_signing"); - - // Update the version of our keys in our cross-signing object and the local store - this._storeTrustedSelfKeys(newCrossSigning.keys); - - const selfSigningChanged = oldSelfSigningId !== newCrossSigning.getId("self_signing"); - const userSigningChanged = oldUserSigningId !== newCrossSigning.getId("user_signing"); - - const selfSigningExistsNotLocallyCached = ( - newCrossSigning.getId("self_signing") && - !crossSigningPrivateKeys.has("self_signing") - ); - const userSigningExistsNotLocallyCached = ( - newCrossSigning.getId("user_signing") && - !crossSigningPrivateKeys.has("user_signing") - ); - - const keySignatures = {}; - - if (selfSigningChanged) { - logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); - } - if ( - allowPrivateKeyRequests && - (selfSigningChanged || selfSigningExistsNotLocallyCached) - ) { - logger.info("Attempting to retrieve cross-signing self-signing private key"); - let signing = null; - try { - const ret = await this._crossSigningInfo.getCrossSigningKey( - "self_signing", newCrossSigning.getId("self_signing"), - ); - signing = ret[1]; - logger.info("Got cross-signing self-signing private key"); - } finally { - if (signing) signing.free(); - } - - const device = this._deviceList.getStoredDevice(this._userId, this._deviceId); - const signedDevice = await this._crossSigningInfo.signDevice( - this._userId, device, - ); - keySignatures[this._deviceId] = signedDevice; - } - if (userSigningChanged) { - logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); - } - if ( - allowPrivateKeyRequests && - (userSigningChanged || userSigningExistsNotLocallyCached) - ) { - logger.info("Attempting to retrieve cross-signing user-signing private key"); - let signing = null; - try { - const ret = await this._crossSigningInfo.getCrossSigningKey( - "user_signing", newCrossSigning.getId("user_signing"), - ); - signing = ret[1]; - logger.info("Got cross-signing user-signing private key"); - } finally { - if (signing) signing.free(); - } - } - - if (masterChanged) { - const masterKey = this._crossSigningInfo.keys.master; - await this._signObject(masterKey); - const deviceSig = masterKey.signatures[this._userId]["ed25519:" + this._deviceId]; - // Include only the _new_ device signature in the upload. - // We may have existing signatures from deleted devices, which will cause - // the entire upload to fail. - keySignatures[this._crossSigningInfo.getId()] = Object.assign( - {}, - masterKey, - { - signatures: { - [this._userId]: { - ["ed25519:" + this._deviceId]: deviceSig, - }, - }, - }, - ); - } - - const keysToUpload = Object.keys(keySignatures); - if (keysToUpload.length) { - const upload = ({ shouldEmit }) => { - logger.info(`Starting background key sig upload for ${keysToUpload}`); - return this._baseApis.uploadKeySignatures({ [this._userId]: keySignatures }) - .then((response) => { - const { failures } = response || {}; - logger.info(`Finished background key sig upload for ${keysToUpload}`); - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this._baseApis.emit( - "crypto.keySignatureUploadFailure", - failures, - "checkOwnCrossSigningTrust", - upload, - ); - } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - }).catch(e => { - logger.error( - `Error during background key sig upload for ${keysToUpload}`, - e, - ); - }); - }; - upload({ shouldEmit: true }); - } - - this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); - - if (masterChanged) { - this._baseApis.emit("crossSigning.keysChanged", {}); - await this._afterCrossSigningLocalKeyChange(); - } - - // Now we may be able to trust our key backup - 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)? -}; - -/** - * Store a set of keys as our own, trusted, cross-signing keys. - * - * @param {object} keys The new trusted set of keys - */ -Crypto.prototype._storeTrustedSelfKeys = async function(keys) { - if (keys) { - this._crossSigningInfo.setKeys(keys); - } else { - this._crossSigningInfo.clearKeys(); - } - await this._cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this._cryptoStore.storeCrossSigningKeys(txn, this._crossSigningInfo.keys); - }, - ); -}; - -/** - * Check if the master key is signed by a verified device, and if so, prompt - * the application to mark it as verified. - * - * @param {string} userId the user ID whose key should be checked - */ -Crypto.prototype._checkDeviceVerifications = async function(userId) { - const shouldUpgradeCb = ( - this._baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications - ); - if (!shouldUpgradeCb) { - // Upgrading skipped when callback is not present. - return; - } - logger.info(`Starting device verification upgrade for ${userId}`); - if (this._crossSigningInfo.keys.user_signing) { - const crossSigningInfo = this._deviceList.getStoredCrossSigningForUser(userId); - if (crossSigningInfo) { - const upgradeInfo = await this._checkForDeviceVerificationUpgrade( - userId, crossSigningInfo, - ); - if (upgradeInfo) { - const usersToUpgrade = await shouldUpgradeCb({ - users: { - [userId]: upgradeInfo, - }, - }); - if (usersToUpgrade.includes(userId)) { - await this._baseApis.setDeviceVerified( - userId, crossSigningInfo.getId(), - ); - } - } - } - } - logger.info(`Finished device verification upgrade for ${userId}`); -}; - -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._backupManager.checkKeyBackup(); -}; - -/** - */ -Crypto.prototype.enableLazyLoading = function() { - this._lazyLoadMembers = true; -}; - -/** - * Tell the crypto module to register for MatrixClient events which it needs to - * listen for - * - * @param {external:EventEmitter} eventEmitter event source where we can register - * for event notifications - */ -Crypto.prototype.registerEventHandlers = function(eventEmitter) { - const crypto = this; - - eventEmitter.on("RoomMember.membership", function(event, member, oldMembership) { - try { - crypto._onRoomMembership(event, member, oldMembership); - } catch (e) { - logger.error("Error handling membership change:", e); - } - }); - - eventEmitter.on("toDeviceEvent", crypto._onToDeviceEvent.bind(crypto)); - - const timelineHandler = crypto._onTimelineEvent.bind(crypto); - - eventEmitter.on("Room.timeline", timelineHandler); - - eventEmitter.on("Event.decrypted", timelineHandler); -}; - -/** Start background processes related to crypto */ -Crypto.prototype.start = function() { - this._outgoingRoomKeyRequestManager.start(); -}; - -/** Stop background processes related to crypto */ -Crypto.prototype.stop = function() { - this._outgoingRoomKeyRequestManager.stop(); - this._deviceList.stop(); - this._dehydrationManager.stop(); -}; - -/** - * @return {string} The version of Olm. - */ -Crypto.getOlmVersion = function() { - return OlmDevice.getOlmVersion(); -}; - -/** - * Get the Ed25519 key for this device - * - * @return {string} base64-encoded ed25519 key. - */ -Crypto.prototype.getDeviceEd25519Key = function() { - return this._olmDevice.deviceEd25519Key; -}; - -/** - * Get the Curve25519 key for this device - * - * @return {string} base64-encoded curve25519 key. - */ -Crypto.prototype.getDeviceCurve25519Key = function() { - return this._olmDevice.deviceCurve25519Key; -}; - -/** - * Set the global override for whether the client should ever send encrypted - * messages to unverified devices. This provides the default for rooms which - * do not specify a value. - * - * @param {boolean} value whether to blacklist all unverified devices by default - */ -Crypto.prototype.setGlobalBlacklistUnverifiedDevices = function(value) { - this._globalBlacklistUnverifiedDevices = value; -}; - -/** - * @return {boolean} whether to blacklist all unverified devices by default - */ -Crypto.prototype.getGlobalBlacklistUnverifiedDevices = function() { - return this._globalBlacklistUnverifiedDevices; -}; - -/** - * Set whether sendMessage in a room with unknown and unverified devices - * should throw an error and not send them message. This has 'Global' for - * symmertry with setGlobalBlacklistUnverifiedDevices but there is currently - * no room-level equivalent for this setting. - * - * This API is currently UNSTABLE and may change or be removed without notice. - * - * @param {boolean} value whether error on unknown devices - */ -Crypto.prototype.setGlobalErrorOnUnknownDevices = function(value) { - this._globalErrorOnUnknownDevices = value; -}; - -/** - * @return {boolean} whether to error on unknown devices - * - * This API is currently UNSTABLE and may change or be removed without notice. - */ -Crypto.prototype.getGlobalErrorOnUnknownDevices = function() { - return this._globalErrorOnUnknownDevices; -}; - -/** - * Upload the device keys to the homeserver. - * @return {object} A promise that will resolve when the keys are uploaded. - */ -Crypto.prototype.uploadDeviceKeys = function() { - const crypto = this; - const userId = crypto._userId; - const deviceId = crypto._deviceId; - - const deviceKeys = { - algorithms: crypto._supportedAlgorithms, - device_id: deviceId, - keys: crypto._deviceKeys, - user_id: userId, - }; - - return crypto._signObject(deviceKeys).then(() => { - return crypto._baseApis.uploadKeysRequest({ - device_keys: deviceKeys, - }); - }); -}; - -/** - * Stores the current one_time_key count which will be handled later (in a call of - * onSyncCompleted). The count is e.g. coming from a /sync response. - * - * @param {Number} currentCount The current count of one_time_keys to be stored - */ -Crypto.prototype.updateOneTimeKeyCount = function(currentCount) { - if (isFinite(currentCount)) { - this._oneTimeKeyCount = currentCount; - } else { - throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number"); - } -}; - -Crypto.prototype.setNeedsNewFallback = function(needsNewFallback) { - this._needsNewFallback = !!needsNewFallback; -}; - -Crypto.prototype.getNeedsNewFallback = function() { - return this._needsNewFallback; -}; - -// check if it's time to upload one-time keys, and do so if so. -function _maybeUploadOneTimeKeys(crypto) { - // frequency with which to check & upload one-time keys - const uploadPeriod = 1000 * 60; // one minute - - // max number of keys to upload at once - // Creating keys can be an expensive operation so we limit the - // number we generate in one go to avoid blocking the application - // for too long. - const maxKeysPerCycle = 5; - - if (crypto._oneTimeKeyCheckInProgress) { - return; - } - - const now = Date.now(); - if (crypto._lastOneTimeKeyCheck !== null && - now - crypto._lastOneTimeKeyCheck < uploadPeriod - ) { - // we've done a key upload recently. - return; - } - - crypto._lastOneTimeKeyCheck = now; - - // We need to keep a pool of one time public keys on the server so that - // other devices can start conversations with us. But we can only store - // a finite number of private keys in the olm Account object. - // To complicate things further then can be a delay between a device - // claiming a public one time key from the server and it sending us a - // message. We need to keep the corresponding private key locally until - // we receive the message. - // But that message might never arrive leaving us stuck with duff - // private keys clogging up our local storage. - // So we need some kind of enginering compromise to balance all of - // these factors. - - // Check how many keys we can store in the Account object. - const maxOneTimeKeys = crypto._olmDevice.maxNumberOfOneTimeKeys(); - // Try to keep at most half that number on the server. This leaves the - // rest of the slots free to hold keys that have been claimed from the - // server but we haven't recevied a message for. - // If we run out of slots when generating new keys then olm will - // discard the oldest private keys first. This will eventually clean - // out stale private keys that won't receive a message. - const keyLimit = Math.floor(maxOneTimeKeys / 2); - - async function uploadLoop(keyCount) { - while (keyLimit > keyCount || crypto.getNeedsNewFallback()) { - // Ask olm to generate new one time keys, then upload them to synapse. - if (keyLimit > keyCount) { - logger.info("generating oneTimeKeys"); - const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle); - await crypto._olmDevice.generateOneTimeKeys(keysThisLoop); - } - - if (crypto.getNeedsNewFallback()) { - logger.info("generating fallback key"); - await crypto._olmDevice.generateFallbackKey(); - } - - logger.info("calling _uploadOneTimeKeys"); - const res = await _uploadOneTimeKeys(crypto); - if (res.one_time_key_counts && res.one_time_key_counts.signed_curve25519) { - // if the response contains a more up to date value use this - // for the next loop - keyCount = res.one_time_key_counts.signed_curve25519; - } else { - throw new Error("response for uploading keys does not contain " + - "one_time_key_counts.signed_curve25519"); - } - } - } - - crypto._oneTimeKeyCheckInProgress = true; - Promise.resolve().then(() => { - if (crypto._oneTimeKeyCount !== undefined) { - // We already have the current one_time_key count from a /sync response. - // Use this value instead of asking the server for the current key count. - return Promise.resolve(crypto._oneTimeKeyCount); - } - // ask the server how many keys we have - return crypto._baseApis.uploadKeysRequest({}).then((res) => { - return res.one_time_key_counts.signed_curve25519 || 0; - }); - }).then((keyCount) => { - // Start the uploadLoop with the current keyCount. The function checks if - // we need to upload new keys or not. - // If there are too many keys on the server then we don't need to - // create any more keys. - return uploadLoop(keyCount); - }).catch((e) => { - logger.error("Error uploading one-time keys", e.stack || e); - }).finally(() => { - // reset _oneTimeKeyCount to prevent start uploading based on old data. - // it will be set again on the next /sync-response - crypto._oneTimeKeyCount = undefined; - crypto._oneTimeKeyCheckInProgress = false; - }); -} - -// returns a promise which resolves to the response -async function _uploadOneTimeKeys(crypto) { - const promises = []; - - const fallbackJson = {}; - if (crypto.getNeedsNewFallback()) { - const fallbackKeys = await crypto._olmDevice.getFallbackKey(); - for (const [keyId, key] of Object.entries(fallbackKeys.curve25519)) { - const k = { key, fallback: true }; - fallbackJson["signed_curve25519:" + keyId] = k; - promises.push(crypto._signObject(k)); - } - crypto.setNeedsNewFallback(false); - } - - const oneTimeKeys = await crypto._olmDevice.getOneTimeKeys(); - const oneTimeJson = {}; - - for (const keyId in oneTimeKeys.curve25519) { - if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { - const k = { - key: oneTimeKeys.curve25519[keyId], - }; - oneTimeJson["signed_curve25519:" + keyId] = k; - promises.push(crypto._signObject(k)); - } - } - - await Promise.all(promises); - - const res = await crypto._baseApis.uploadKeysRequest({ - "one_time_keys": oneTimeJson, - "org.matrix.msc2732.fallback_keys": fallbackJson, - }); - - await crypto._olmDevice.markKeysAsPublished(); - return res; -} - -/** - * Download the keys for a list of users and stores the keys in the session - * store. - * @param {Array} userIds The users to fetch. - * @param {bool} forceDownload Always download the keys even if cached. - * - * @return {Promise} A promise which resolves to a map userId->deviceId->{@link - * module:crypto/deviceinfo|DeviceInfo}. - */ -Crypto.prototype.downloadKeys = function(userIds, forceDownload) { - return this._deviceList.downloadKeys(userIds, forceDownload); -}; - -/** - * Get the stored device keys for a user id - * - * @param {string} userId the user to list keys for. - * - * @return {module:crypto/deviceinfo[]|null} list of devices, or null if we haven't - * managed to get a list of devices for this user yet. - */ -Crypto.prototype.getStoredDevicesForUser = function(userId) { - return this._deviceList.getStoredDevicesForUser(userId); -}; - -/** - * Get the stored keys for a single device - * - * @param {string} userId - * @param {string} deviceId - * - * @return {module:crypto/deviceinfo?} device, or undefined - * if we don't know about this device - */ -Crypto.prototype.getStoredDevice = function(userId, deviceId) { - return this._deviceList.getStoredDevice(userId, deviceId); -}; - -/** - * Save the device list, if necessary - * - * @param {integer} delay Time in ms before which the save actually happens. - * By default, the save is delayed for a short period in order to batch - * multiple writes, but this behaviour can be disabled by passing 0. - * - * @return {Promise} true if the data was saved, false if - * it was not (eg. because no changes were pending). The promise - * will only resolve once the data is saved, so may take some time - * to resolve. - */ -Crypto.prototype.saveDeviceList = function(delay) { - return this._deviceList.saveIfDirty(delay); -}; - -/** - * Update the blocked/verified state of the given device - * - * @param {string} userId owner of the device - * @param {string} deviceId unique identifier for the device or user's - * cross-signing public key ID. - * - * @param {?boolean} verified whether to mark the device as verified. Null to - * leave unchanged. - * - * @param {?boolean} blocked whether to mark the device as blocked. Null to - * leave unchanged. - * - * @param {?boolean} known whether to mark that the user has been made aware of - * the existence of this device. Null to leave unchanged - * - * @return {Promise} updated DeviceInfo - */ -Crypto.prototype.setDeviceVerification = async function( - userId, deviceId, verified, blocked, known, -) { - // get rid of any `undefined`s here so we can just check - // for null rather than null or undefined - if (verified === undefined) verified = null; - if (blocked === undefined) blocked = null; - if (known === undefined) known = null; - - // Check if the 'device' is actually a cross signing key - // The js-sdk's verification treats cross-signing keys as devices - // and so uses this method to mark them verified. - const xsk = this._deviceList.getStoredCrossSigningForUser(userId); - if (xsk && xsk.getId() === deviceId) { - if (blocked !== null || known !== null) { - throw new Error("Cannot set blocked or known for a cross-signing key"); - } - if (!verified) { - throw new Error("Cannot set a cross-signing key as unverified"); - } - - if (!this._crossSigningInfo.getId() && userId === this._crossSigningInfo.userId) { - this._storeTrustedSelfKeys(xsk.keys); - // This will cause our own user trust to change, so emit the event - this.emit( - "userTrustStatusChanged", this._userId, this.checkUserTrust(userId), - ); - } - - // Now sign the master key with our user signing key (unless it's ourself) - if (userId !== this._userId) { - logger.info( - "Master key " + xsk.getId() + " for " + userId + - " marked verified. Signing...", - ); - const device = await this._crossSigningInfo.signUser(xsk); - if (device) { - const upload = async ({ shouldEmit }) => { - logger.info("Uploading signature for " + userId + "..."); - const response = await this._baseApis.uploadKeySignatures({ - [userId]: { - [deviceId]: device, - }, - }); - const { failures } = response || {}; - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this._baseApis.emit( - "crypto.keySignatureUploadFailure", - failures, - "setDeviceVerification", - upload, - ); - } - /* Throwing here causes the process to be cancelled and the other - * user to be notified */ - throw new KeySignatureUploadError( - "Key upload failed", - { failures }, - ); - } - }; - await upload({ shouldEmit: true }); - - // This will emit events when it comes back down the sync - // (we could do local echo to speed things up) - } - return device; - } else { - return xsk; - } - } - - const devices = this._deviceList.getRawStoredDevicesForUser(userId); - if (!devices || !devices[deviceId]) { - throw new Error("Unknown device " + userId + ":" + deviceId); - } - - const dev = devices[deviceId]; - let verificationStatus = dev.verified; - - if (verified) { - verificationStatus = DeviceVerification.VERIFIED; - } else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) { - verificationStatus = DeviceVerification.UNVERIFIED; - } - - if (blocked) { - verificationStatus = DeviceVerification.BLOCKED; - } else if (blocked !== null && verificationStatus == DeviceVerification.BLOCKED) { - verificationStatus = DeviceVerification.UNVERIFIED; - } - - let knownStatus = dev.known; - if (known !== null) { - knownStatus = known; - } - - if (dev.verified !== verificationStatus || dev.known !== knownStatus) { - dev.verified = verificationStatus; - dev.known = knownStatus; - this._deviceList.storeDevicesForUser(userId, devices); - this._deviceList.saveIfDirty(); - } - - // do cross-signing - if (verified && userId === this._userId) { - logger.info("Own device " + deviceId + " marked verified: signing"); - - // Signing only needed if other device not already signed - let device; - const deviceTrust = this.checkDeviceTrust(userId, deviceId); - if (deviceTrust.isCrossSigningVerified()) { - logger.log(`Own device ${deviceId} already cross-signing verified`); - } else { - device = await this._crossSigningInfo.signDevice( - userId, DeviceInfo.fromStorage(dev, deviceId), - ); - } - - if (device) { - const upload = async ({ shouldEmit }) => { - logger.info("Uploading signature for " + deviceId); - const response = await this._baseApis.uploadKeySignatures({ - [userId]: { - [deviceId]: device, - }, - }); - const { failures } = response || {}; - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this._baseApis.emit( - "crypto.keySignatureUploadFailure", - failures, - "setDeviceVerification", - upload, // continuation - ); - } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - }; - await upload({ shouldEmit: true }); - // XXX: we'll need to wait for the device list to be updated - } - } - - const deviceObj = DeviceInfo.fromStorage(dev, deviceId); - this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); - return deviceObj; -}; - -Crypto.prototype.findVerificationRequestDMInProgress = function(roomId) { - return this._inRoomVerificationRequests.findRequestInProgress(roomId); -}; - -Crypto.prototype.getVerificationRequestsToDeviceInProgress = function(userId) { - return this._toDeviceVerificationRequests.getRequestsInProgress(userId); -}; - -Crypto.prototype.requestVerificationDM = function(userId, roomId) { - const existingRequest = this._inRoomVerificationRequests. - findRequestInProgress(roomId); - if (existingRequest) { - return Promise.resolve(existingRequest); - } - const channel = new InRoomChannel(this._baseApis, roomId, userId); - return this._requestVerificationWithChannel( - userId, - channel, - this._inRoomVerificationRequests, - ); -}; - -Crypto.prototype.requestVerification = function(userId, devices) { - if (!devices) { - devices = Object.keys(this._deviceList.getRawStoredDevicesForUser(userId)); - } - const existingRequest = this._toDeviceVerificationRequests - .findRequestInProgress(userId, devices); - if (existingRequest) { - return Promise.resolve(existingRequest); - } - const channel = new ToDeviceChannel(this._baseApis, userId, devices, - ToDeviceChannel.makeTransactionId()); - return this._requestVerificationWithChannel( - userId, - channel, - this._toDeviceVerificationRequests, - ); -}; - -Crypto.prototype._requestVerificationWithChannel = async function( - userId, channel, requestsMap, -) { - let request = new VerificationRequest( - channel, this._verificationMethods, this._baseApis); - // if transaction id is already known, add request - if (channel.transactionId) { - requestsMap.setRequestByChannel(channel, request); - } - await request.sendRequest(); - // don't replace the request created by a racing remote echo - const racingRequest = requestsMap.getRequestByChannel(channel); - if (racingRequest) { - request = racingRequest; - } else { - logger.log(`Crypto: adding new request to ` + - `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`); - requestsMap.setRequestByChannel(channel, request); - } - return request; -}; - -Crypto.prototype.beginKeyVerification = function( - method, userId, deviceId, transactionId = null, -) { - let request; - if (transactionId) { - request = this._toDeviceVerificationRequests.getRequestBySenderAndTxnId( - userId, transactionId); - if (!request) { - throw new Error( - `No request found for user ${userId} with ` + - `transactionId ${transactionId}`); - } - } else { - transactionId = ToDeviceChannel.makeTransactionId(); - const channel = new ToDeviceChannel( - this._baseApis, userId, [deviceId], transactionId, deviceId); - request = new VerificationRequest( - channel, this._verificationMethods, this._baseApis); - this._toDeviceVerificationRequests.setRequestBySenderAndTxnId( - userId, transactionId, request); - } - return request.beginKeyVerification(method, { userId, deviceId }); -}; - -Crypto.prototype.legacyDeviceVerification = async function( - userId, deviceId, method, -) { - const transactionId = ToDeviceChannel.makeTransactionId(); - const channel = new ToDeviceChannel( - this._baseApis, userId, [deviceId], transactionId, deviceId); - const request = new VerificationRequest( - channel, this._verificationMethods, this._baseApis); - this._toDeviceVerificationRequests.setRequestBySenderAndTxnId( - userId, transactionId, request); - const verifier = request.beginKeyVerification(method, { userId, deviceId }); - // either reject by an error from verify() while sending .start - // or resolve when the request receives the - // local (fake remote) echo for sending the .start event - await Promise.race([ - verifier.verify(), - request.waitFor(r => r.started), - ]); - return request; -}; - -/** - * Get information on the active olm sessions with a user - *

- * Returns a map from device id to an object with keys 'deviceIdKey' (the - * device's curve25519 identity key) and 'sessions' (an array of objects in the - * same format as that returned by - * {@link module:crypto/OlmDevice#getSessionInfoForDevice}). - *

- * This method is provided for debugging purposes. - * - * @param {string} userId id of user to inspect - * - * @return {Promise>} - */ -Crypto.prototype.getOlmSessionsForUser = async function(userId) { - const devices = this.getStoredDevicesForUser(userId) || []; - const result = {}; - for (let j = 0; j < devices.length; ++j) { - const device = devices[j]; - const deviceKey = device.getIdentityKey(); - const sessions = await this._olmDevice.getSessionInfoForDevice(deviceKey); - - result[device.deviceId] = { - deviceIdKey: deviceKey, - sessions: sessions, - }; - } - return result; -}; - -/** - * Get the device which sent an event - * - * @param {module:models/event.MatrixEvent} event event to be checked - * - * @return {module:crypto/deviceinfo?} - */ -Crypto.prototype.getEventSenderDeviceInfo = function(event) { - const senderKey = event.getSenderKey(); - const algorithm = event.getWireContent().algorithm; - - if (!senderKey || !algorithm) { - return null; - } - - const forwardingChain = event.getForwardingCurve25519KeyChain(); - if (forwardingChain.length > 0) { - // we got the key this event from somewhere else - // TODO: check if we can trust the forwarders. - return null; - } - - if (event.isKeySourceUntrusted()) { - // we got the key for this event from a source that we consider untrusted - return null; - } - - // senderKey is the Curve25519 identity key of the device which the event - // was sent from. In the case of Megolm, it's actually the Curve25519 - // identity key of the device which set up the Megolm session. - - const device = this._deviceList.getDeviceByIdentityKey( - algorithm, senderKey, - ); - - if (device === null) { - // we haven't downloaded the details of this device yet. - return null; - } - - // so far so good, but now we need to check that the sender of this event - // hadn't advertised someone else's Curve25519 key as their own. We do that - // by checking the Ed25519 claimed by the event (or, in the case of megolm, - // the event which set up the megolm session), to check that it matches the - // fingerprint of the purported sending device. - // - // (see https://github.com/vector-im/vector-web/issues/2215) - - const claimedKey = event.getClaimedEd25519Key(); - if (!claimedKey) { - logger.warn("Event " + event.getId() + " claims no ed25519 key: " + - "cannot verify sending device"); - return null; - } - - if (claimedKey !== device.getFingerprint()) { - logger.warn( - "Event " + event.getId() + " claims ed25519 key " + claimedKey + - " but sender device has key " + device.getFingerprint()); - return null; - } - - return device; -}; - -/** - * Get information about the encryption of an event - * - * @param {module:models/event.MatrixEvent} event event to be checked - * - * @return {object} An object with the fields: - * - encrypted: whether the event is encrypted (if not encrypted, some of the - * other properties may not be set) - * - senderKey: the sender's key - * - algorithm: the algorithm used to encrypt the event - * - authenticated: whether we can be sure that the owner of the senderKey - * sent the event - * - sender: the sender's device information, if available - * - mismatchedSender: if the event's ed25519 and curve25519 keys don't match - * (only meaningful if `sender` is set) - */ -Crypto.prototype.getEventEncryptionInfo = function(event) { - const ret = {}; - - ret.senderKey = event.getSenderKey(); - ret.algorithm = event.getWireContent().algorithm; - - if (!ret.senderKey || !ret.algorithm) { - ret.encrypted = false; - return ret; - } - ret.encrypted = true; - - const forwardingChain = event.getForwardingCurve25519KeyChain(); - if (forwardingChain.length > 0 || event.isKeySourceUntrusted()) { - // we got the key this event from somewhere else - // TODO: check if we can trust the forwarders. - ret.authenticated = false; - } else { - ret.authenticated = true; - } - - // senderKey is the Curve25519 identity key of the device which the event - // was sent from. In the case of Megolm, it's actually the Curve25519 - // identity key of the device which set up the Megolm session. - - ret.sender = this._deviceList.getDeviceByIdentityKey( - ret.algorithm, ret.senderKey, - ); - - // so far so good, but now we need to check that the sender of this event - // hadn't advertised someone else's Curve25519 key as their own. We do that - // by checking the Ed25519 claimed by the event (or, in the case of megolm, - // the event which set up the megolm session), to check that it matches the - // fingerprint of the purported sending device. - // - // (see https://github.com/vector-im/vector-web/issues/2215) - - const claimedKey = event.getClaimedEd25519Key(); - if (!claimedKey) { - logger.warn("Event " + event.getId() + " claims no ed25519 key: " + - "cannot verify sending device"); - ret.mismatchedSender = true; - } - - if (ret.sender && claimedKey !== ret.sender.getFingerprint()) { - logger.warn( - "Event " + event.getId() + " claims ed25519 key " + claimedKey + - "but sender device has key " + ret.sender.getFingerprint()); - ret.mismatchedSender = true; - } - - return ret; -}; - -/** - * Forces the current outbound group session to be discarded such - * that another one will be created next time an event is sent. - * - * @param {string} roomId The ID of the room to discard the session for - * - * This should not normally be necessary. - */ -Crypto.prototype.forceDiscardSession = function(roomId) { - const alg = this._roomEncryptors[roomId]; - if (alg === undefined) throw new Error("Room not encrypted"); - if (alg.forceDiscardSession === undefined) { - throw new Error("Room encryption algorithm doesn't support session discarding"); - } - alg.forceDiscardSession(); -}; - -/** - * Configure a room to use encryption (ie, save a flag in the cryptoStore). - * - * @param {string} roomId The room ID to enable encryption in. - * - * @param {object} config The encryption config for the room. - * - * @param {boolean=} inhibitDeviceQuery true to suppress device list query for - * users in the room (for now). In case lazy loading is enabled, - * the device query is always inhibited as the members are not tracked. - */ -Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDeviceQuery) { - // ignore crypto events with no algorithm defined - // This will happen if a crypto event is redacted before we fetch the room state - // It would otherwise just throw later as an unknown algorithm would, but we may - // as well catch this here - if (!config.algorithm) { - logger.log("Ignoring setRoomEncryption with no algorithm"); - return; - } - - // if state is being replayed from storage, we might already have a configuration - // for this room as they are persisted as well. - // We just need to make sure the algorithm is initialized in this case. - // However, if the new config is different, - // we should bail out as room encryption can't be changed once set. - const existingConfig = this._roomList.getRoomEncryption(roomId); - if (existingConfig) { - if (JSON.stringify(existingConfig) != JSON.stringify(config)) { - logger.error("Ignoring m.room.encryption event which requests " + - "a change of config in " + roomId); - return; - } - } - // if we already have encryption in this room, we should ignore this event, - // as it would reset the encryption algorithm. - // This is at least expected to be called twice, as sync calls onCryptoEvent - // for both the timeline and state sections in the /sync response, - // the encryption event would appear in both. - // If it's called more than twice though, - // it signals a bug on client or server. - const existingAlg = this._roomEncryptors[roomId]; - if (existingAlg) { - return; - } - - // _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption - // because it first stores in memory. We should await the promise only - // after all the in-memory state (_roomEncryptors and _roomList) has been updated - // to avoid races when calling this method multiple times. Hence keep a hold of the promise. - let storeConfigPromise = null; - if (!existingConfig) { - storeConfigPromise = this._roomList.setRoomEncryption(roomId, config); - } - - const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm]; - if (!AlgClass) { - throw new Error("Unable to encrypt with " + config.algorithm); - } - - const alg = new AlgClass({ - userId: this._userId, - deviceId: this._deviceId, - crypto: this, - olmDevice: this._olmDevice, - baseApis: this._baseApis, - roomId: roomId, - config: config, - }); - this._roomEncryptors[roomId] = alg; - - if (storeConfigPromise) { - await storeConfigPromise; - } - - if (!this._lazyLoadMembers) { - logger.log("Enabling encryption in " + roomId + "; " + - "starting to track device lists for all users therein"); - - await this.trackRoomDevices(roomId); - // TODO: this flag is only not used from MatrixClient::setRoomEncryption - // which is never used (inside Element at least) - // but didn't want to remove it as it technically would - // be a breaking change. - if (!this.inhibitDeviceQuery) { - this._deviceList.refreshOutdatedDeviceLists(); - } - } else { - logger.log("Enabling encryption in " + roomId); - } -}; - -/** - * Make sure we are tracking the device lists for all users in this room. - * - * @param {string} roomId The room ID to start tracking devices in. - * @returns {Promise} when all devices for the room have been fetched and marked to track - */ -Crypto.prototype.trackRoomDevices = function(roomId) { - const trackMembers = async () => { - // not an encrypted room - if (!this._roomEncryptors[roomId]) { - return; - } - const room = this._clientStore.getRoom(roomId); - if (!room) { - throw new Error(`Unable to start tracking devices in unknown room ${roomId}`); - } - logger.log(`Starting to track devices for room ${roomId} ...`); - const members = await room.getEncryptionTargetMembers(); - members.forEach((m) => { - this._deviceList.startTrackingDeviceList(m.userId); - }); - }; - - let promise = this._roomDeviceTrackingState[roomId]; - if (!promise) { - promise = trackMembers(); - this._roomDeviceTrackingState[roomId] = promise.catch(err => { - this._roomDeviceTrackingState[roomId] = null; - throw err; - }); - } - return promise; -}; - -/** - * @typedef {Object} module:crypto~OlmSessionResult - * @property {module:crypto/deviceinfo} device device info - * @property {string?} sessionId base64 olm session id; null if no session - * could be established - */ - -/** - * Try to make sure we have established olm sessions for all known devices for - * the given users. - * - * @param {string[]} users list of user ids - * - * @return {Promise} resolves once the sessions are complete, to - * an Object mapping from userId to deviceId to - * {@link module:crypto~OlmSessionResult} - */ -Crypto.prototype.ensureOlmSessionsForUsers = function(users) { - const devicesByUser = {}; - - for (let i = 0; i < users.length; ++i) { - const userId = users[i]; - devicesByUser[userId] = []; - - const devices = this.getStoredDevicesForUser(userId) || []; - for (let j = 0; j < devices.length; ++j) { - const deviceInfo = devices[j]; - - const key = deviceInfo.getIdentityKey(); - if (key == this._olmDevice.deviceCurve25519Key) { - // don't bother setting up session to ourself - continue; - } - if (deviceInfo.verified == DeviceVerification.BLOCKED) { - // don't bother setting up sessions with blocked users - continue; - } - - devicesByUser[userId].push(deviceInfo); - } - } - - return olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, - ); -}; - -/** - * Get a list containing all of the room keys - * - * @return {module:crypto/OlmDevice.MegolmSessionData[]} a list of session export objects - */ -Crypto.prototype.exportRoomKeys = async function() { - const exportedSessions = []; - await this._cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { - this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => { - if (s === null) return; - - const sess = this._olmDevice.exportInboundGroupSession( - s.senderKey, s.sessionId, s.sessionData, - ); - delete sess.first_known_index; - sess.algorithm = olmlib.MEGOLM_ALGORITHM; - exportedSessions.push(sess); - }); - }, - ); - - return exportedSessions; -}; - -/** - * Import a list of room keys previously exported by exportRoomKeys - * - * @param {Object[]} keys a list of session export objects - * @param {Object} opts - * @param {Function} opts.progressCallback called with an object which has a stage param - * @return {Promise} a promise which resolves once the keys have been imported - */ -Crypto.prototype.importRoomKeys = function(keys, opts = {}) { - let successes = 0; - let failures = 0; - const total = keys.length; - - function updateProgress() { - opts.progressCallback({ - stage: "load_keys", - successes, - failures, - total, - }); - } - - return Promise.all(keys.map((key) => { - if (!key.room_id || !key.algorithm) { - logger.warn("ignoring room key entry with missing fields", key); - failures++; - if (opts.progressCallback) { updateProgress(); } - return null; - } - - const alg = this._getRoomDecryptor(key.room_id, key.algorithm); - return alg.importRoomKey(key, opts).finally((r) => { - successes++; - if (opts.progressCallback) { updateProgress(); } - }); - })); -}; - -/** - * Counts the number of end to end session keys that are waiting to be backed up - * @returns {Promise} Resolves to the number of sessions requiring backup - */ -Crypto.prototype.countSessionsNeedingBackup = function() { - return this._backupManager.countSessionsNeedingBackup(); -}; - -/** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * - * @param {module:models/room} room the room the event is in - */ -Crypto.prototype.prepareToEncrypt = function(room) { - const roomId = room.roomId; - const alg = this._roomEncryptors[roomId]; - if (alg) { - alg.prepareToEncrypt(room); - } -}; - -/* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307 -/** - * Encrypt an event according to the configuration of the room. - * - * @param {module:models/event.MatrixEvent} event event to be sent - * - * @param {module:models/room} room destination room. - * - * @return {Promise?} Promise which resolves when the event has been - * encrypted, or null if nothing was needed - */ -/* eslint-enable valid-jsdoc */ -Crypto.prototype.encryptEvent = async function(event, room) { - if (!room) { - throw new Error("Cannot send encrypted messages in unknown rooms"); - } - - const roomId = event.getRoomId(); - - const alg = this._roomEncryptors[roomId]; - if (!alg) { - // MatrixClient has already checked that this room should be encrypted, - // so this is an unexpected situation. - throw new Error( - "Room was previously configured to use encryption, but is " + - "no longer. Perhaps the homeserver is hiding the " + - "configuration event.", - ); - } - - if (!this._roomDeviceTrackingState[roomId]) { - this.trackRoomDevices(roomId); - } - // wait for all the room devices to be loaded - await this._roomDeviceTrackingState[roomId]; - - let content = event.getContent(); - // If event has an m.relates_to then we need - // to put this on the wrapping event instead - const mRelatesTo = content['m.relates_to']; - if (mRelatesTo) { - // Clone content here so we don't remove `m.relates_to` from the local-echo - content = Object.assign({}, content); - delete content['m.relates_to']; - } - - const encryptedContent = await alg.encryptMessage( - room, event.getType(), content); - - if (mRelatesTo) { - encryptedContent['m.relates_to'] = mRelatesTo; - } - - event.makeEncrypted( - "m.room.encrypted", - encryptedContent, - this._olmDevice.deviceCurve25519Key, - this._olmDevice.deviceEd25519Key, - ); -}; - -/** - * Decrypt a received event - * - * @param {MatrixEvent} event - * - * @return {Promise} resolves once we have - * finished decrypting. Rejects with an `algorithms.DecryptionError` if there - * is a problem decrypting the event. - */ -Crypto.prototype.decryptEvent = async function(event) { - if (event.isRedacted()) { - const redactionEvent = new MatrixEvent(event.getUnsigned().redacted_because); - const decryptedEvent = await this.decryptEvent(redactionEvent); - - return { - clearEvent: { - room_id: event.getRoomId(), - type: "m.room.message", - content: {}, - unsigned: { - redacted_because: decryptedEvent.clearEvent, - }, - }, - }; - } else { - const content = event.getWireContent(); - const alg = this._getRoomDecryptor(event.getRoomId(), content.algorithm); - return await alg.decryptEvent(event); - } -}; - -/** - * Handle the notification from /sync or /keys/changes that device lists have - * been changed. - * - * @param {Object} syncData Object containing sync tokens associated with this sync - * @param {Object} syncDeviceLists device_lists field from /sync, or response from - * /keys/changes - */ -Crypto.prototype.handleDeviceListChanges = async function(syncData, syncDeviceLists) { - // Initial syncs don't have device change lists. We'll either get the complete list - // of changes for the interval or will have invalidated everything in willProcessSync - if (!syncData.oldSyncToken) return; - - // Here, we're relying on the fact that we only ever save the sync data after - // sucessfully saving the device list data, so we're guaranteed that the device - // list store is at least as fresh as the sync token from the sync store, ie. - // any device changes received in sync tokens prior to the 'next' token here - // have been processed and are reflected in the current device list. - // If we didn't make this assumption, we'd have to use the /keys/changes API - // to get key changes between the sync token in the device list and the 'old' - // sync token used here to make sure we didn't miss any. - await this._evalDeviceListChanges(syncDeviceLists); -}; - -/** - * Send a request for some room keys, if we have not already done so - * - * @param {module:crypto~RoomKeyRequestBody} requestBody - * @param {Array<{userId: string, deviceId: string}>} recipients - * @param {boolean} resend whether to resend the key request if there is - * already one - * - * @return {Promise} a promise that resolves when the key request is queued - */ -Crypto.prototype.requestRoomKey = function(requestBody, recipients, resend=false) { - return this._outgoingRoomKeyRequestManager.queueRoomKeyRequest( - requestBody, recipients, resend, - ).then(() => { - if (this._sendKeyRequestsImmediately) { - this._outgoingRoomKeyRequestManager.sendQueuedRequests(); - } - }).catch((e) => { - // this normally means we couldn't talk to the store - logger.error( - 'Error requesting key for event', e, - ); - }); -}; - -/** - * Cancel any earlier room key request - * - * @param {module:crypto~RoomKeyRequestBody} requestBody - * parameters to match for cancellation - */ -Crypto.prototype.cancelRoomKeyRequest = function(requestBody) { - this._outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody) - .catch((e) => { - logger.warn("Error clearing pending room key requests", e); - }); -}; - -/** - * Re-send any outgoing key requests, eg after verification - * @returns {Promise} - */ -Crypto.prototype.cancelAndResendAllOutgoingKeyRequests = function() { - return this._outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); -}; - -/** - * handle an m.room.encryption event - * - * @param {module:models/event.MatrixEvent} event encryption event - */ -Crypto.prototype.onCryptoEvent = async function(event) { - const roomId = event.getRoomId(); - const content = event.getContent(); - - try { - // inhibit the device list refresh for now - it will happen once we've - // finished processing the sync, in onSyncCompleted. - await this.setRoomEncryption(roomId, content, true); - } catch (e) { - logger.error("Error configuring encryption in room " + roomId + - ":", e); - } -}; - -/** - * Called before the result of a sync is procesed - * - * @param {Object} syncData the data from the 'MatrixClient.sync' event - */ -Crypto.prototype.onSyncWillProcess = async function(syncData) { - if (!syncData.oldSyncToken) { - // If there is no old sync token, we start all our tracking from - // scratch, so mark everything as untracked. onCryptoEvent will - // be called for all e2e rooms during the processing of the sync, - // at which point we'll start tracking all the users of that room. - logger.log("Initial sync performed - resetting device tracking state"); - this._deviceList.stopTrackingAllDeviceLists(); - // we always track our own device list (for key backups etc) - this._deviceList.startTrackingDeviceList(this._userId); - this._roomDeviceTrackingState = {}; - } - - this._sendKeyRequestsImmediately = false; -}; - -/** - * handle the completion of a /sync - * - * This is called after the processing of each successful /sync response. - * It is an opportunity to do a batch process on the information received. - * - * @param {Object} syncData the data from the 'MatrixClient.sync' event - */ -Crypto.prototype.onSyncCompleted = async function(syncData) { - const nextSyncToken = syncData.nextSyncToken; - - this._deviceList.setSyncToken(syncData.nextSyncToken); - this._deviceList.saveIfDirty(); - - // catch up on any new devices we got told about during the sync. - this._deviceList.lastKnownSyncToken = nextSyncToken; - - // we always track our own device list (for key backups etc) - this._deviceList.startTrackingDeviceList(this._userId); - - this._deviceList.refreshOutdatedDeviceLists(); - - // we don't start uploading one-time keys until we've caught up with - // to-device messages, to help us avoid throwing away one-time-keys that we - // are about to receive messages for - // (https://github.com/vector-im/element-web/issues/2782). - if (!syncData.catchingUp) { - _maybeUploadOneTimeKeys(this); - this._processReceivedRoomKeyRequests(); - - // likewise don't start requesting keys until we've caught up - // on to_device messages, otherwise we'll request keys that we're - // just about to get. - this._outgoingRoomKeyRequestManager.sendQueuedRequests(); - - // Sync has finished so send key requests straight away. - this._sendKeyRequestsImmediately = true; - } -}; - -/** - * Trigger the appropriate invalidations and removes for a given - * device list - * - * @param {Object} deviceLists device_lists field from /sync, or response from - * /keys/changes - */ -Crypto.prototype._evalDeviceListChanges = async function(deviceLists) { - if (deviceLists.changed && Array.isArray(deviceLists.changed)) { - deviceLists.changed.forEach((u) => { - this._deviceList.invalidateUserDeviceList(u); - }); - } - - if (deviceLists.left && Array.isArray(deviceLists.left) && - deviceLists.left.length) { - // Check we really don't share any rooms with these users - // any more: the server isn't required to give us the - // exact correct set. - const e2eUserIds = new Set(await this._getTrackedE2eUsers()); - - deviceLists.left.forEach((u) => { - if (!e2eUserIds.has(u)) { - this._deviceList.stopTrackingDeviceList(u); - } - }); - } -}; - -/** - * Get a list of all the IDs of users we share an e2e room with - * for which we are tracking devices already - * - * @returns {string[]} List of user IDs - */ -Crypto.prototype._getTrackedE2eUsers = async function() { - const e2eUserIds = []; - for (const room of this._getTrackedE2eRooms()) { - const members = await room.getEncryptionTargetMembers(); - for (const member of members) { - e2eUserIds.push(member.userId); - } - } - return e2eUserIds; -}; - -/** - * Get a list of the e2e-enabled rooms we are members of, - * and for which we are already tracking the devices - * - * @returns {module:models.Room[]} - */ -Crypto.prototype._getTrackedE2eRooms = function() { - return this._clientStore.getRooms().filter((room) => { - // check for rooms with encryption enabled - const alg = this._roomEncryptors[room.roomId]; - if (!alg) { - return false; - } - if (!this._roomDeviceTrackingState[room.roomId]) { - return false; - } - - // ignore any rooms which we have left - const myMembership = room.getMyMembership(); - return myMembership === "join" || myMembership === "invite"; - }); -}; - -Crypto.prototype._onToDeviceEvent = function(event) { - try { - logger.log(`received to_device ${event.getType()} from: ` + - `${event.getSender()} id: ${event.getId()}`); - - if (event.getType() == "m.room_key" - || event.getType() == "m.forwarded_room_key") { - this._onRoomKeyEvent(event); - } else if (event.getType() == "m.room_key_request") { - this._onRoomKeyRequestEvent(event); - } else if (event.getType() === "m.secret.request") { - this._secretStorage._onRequestReceived(event); - } else if (event.getType() === "m.secret.send") { - this._secretStorage._onSecretReceived(event); - } else if (event.getType() === "org.matrix.room_key.withheld") { - this._onRoomKeyWithheldEvent(event); - } else if (event.getContent().transaction_id) { - this._onKeyVerificationMessage(event); - } else if (event.getContent().msgtype === "m.bad.encrypted") { - this._onToDeviceBadEncrypted(event); - } else if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { - if (!event.isBeingDecrypted()) { - event.attemptDecryption(this); - } - // once the event has been decrypted, try again - event.once('Event.decrypted', (ev) => { - this._onToDeviceEvent(ev); - }); - } - } catch (e) { - logger.error("Error handling toDeviceEvent:", e); - } -}; - -/** - * Handle a key event - * - * @private - * @param {module:models/event.MatrixEvent} event key event - */ -Crypto.prototype._onRoomKeyEvent = function(event) { - const content = event.getContent(); - - if (!content.room_id || !content.algorithm) { - logger.error("key event is missing fields"); - return; - } - - 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._backupManager.checkAndStart(); - } - - const alg = this._getRoomDecryptor(content.room_id, content.algorithm); - alg.onRoomKeyEvent(event); -}; - -/** - * Handle a key withheld event - * - * @private - * @param {module:models/event.MatrixEvent} event key withheld event - */ -Crypto.prototype._onRoomKeyWithheldEvent = function(event) { - const content = event.getContent(); - - if ((content.code !== "m.no_olm" && (!content.room_id || !content.session_id)) - || !content.algorithm || !content.sender_key) { - logger.error("key withheld event is missing fields"); - return; - } - - logger.info( - `Got room key withheld event from ${event.getSender()} (${content.sender_key}) ` - + `for ${content.algorithm}/${content.room_id}/${content.session_id} ` - + `with reason ${content.code} (${content.reason})`, - ); - - const alg = this._getRoomDecryptor(content.room_id, content.algorithm); - if (alg.onRoomKeyWithheldEvent) { - alg.onRoomKeyWithheldEvent(event); - } - if (!content.room_id) { - // retry decryption for all events sent by the sender_key. This will - // update the events to show a message indicating that the olm session was - // wedged. - const roomDecryptors = this._getRoomDecryptors(content.algorithm); - for (const decryptor of roomDecryptors) { - decryptor.retryDecryptionFromSender(content.sender_key); - } - } -}; - -/** - * Handle a general key verification event. - * - * @private - * @param {module:models/event.MatrixEvent} event verification start event - */ -Crypto.prototype._onKeyVerificationMessage = function(event) { - if (!ToDeviceChannel.validateEvent(event, this._baseApis)) { - return; - } - const createRequest = event => { - if (!ToDeviceChannel.canCreateRequest(ToDeviceChannel.getEventType(event))) { - return; - } - const content = event.getContent(); - const deviceId = content && content.from_device; - if (!deviceId) { - return; - } - const userId = event.getSender(); - const channel = new ToDeviceChannel( - this._baseApis, - userId, - [deviceId], - ); - return new VerificationRequest( - channel, this._verificationMethods, this._baseApis); - }; - this._handleVerificationEvent( - event, - this._toDeviceVerificationRequests, - createRequest, - ); -}; - -/** - * Handle key verification requests sent as timeline events - * - * @private - * @param {module:models/event.MatrixEvent} event the timeline event - * @param {module:models/Room} room not used - * @param {bool} atStart not used - * @param {bool} removed not used - * @param {bool} data.liveEvent whether this is a live event - */ -Crypto.prototype._onTimelineEvent = function( - event, room, atStart, removed, { liveEvent } = {}, -) { - if (!InRoomChannel.validateEvent(event, this._baseApis)) { - return; - } - const createRequest = event => { - const channel = new InRoomChannel( - this._baseApis, - event.getRoomId(), - ); - return new VerificationRequest( - channel, this._verificationMethods, this._baseApis); - }; - this._handleVerificationEvent( - event, - this._inRoomVerificationRequests, - createRequest, - liveEvent, - ); -}; - -Crypto.prototype._handleVerificationEvent = async function( - event, requestsMap, createRequest, isLiveEvent = true, -) { - let request = requestsMap.getRequest(event); - let isNewRequest = false; - if (!request) { - request = createRequest(event); - // a request could not be made from this event, so ignore event - if (!request) { - logger.log(`Crypto: could not find VerificationRequest for ` + - `${event.getType()}, and could not create one, so ignoring.`); - return; - } - isNewRequest = true; - requestsMap.setRequest(event, request); - } - event.setVerificationRequest(request); - try { - await request.channel.handleEvent(event, request, isLiveEvent); - } catch (err) { - logger.error("error while handling verification event: " + err.message); - } - const shouldEmit = isNewRequest && - !request.initiatedByMe && - !request.invalid && // check it has enough events to pass the UNSENT stage - !request.observeOnly; - if (shouldEmit) { - this._baseApis.emit("crypto.verification.request", request); - } -}; - -/** - * Handle a toDevice event that couldn't be decrypted - * - * @private - * @param {module:models/event.MatrixEvent} event undecryptable event - */ -Crypto.prototype._onToDeviceBadEncrypted = async function(event) { - const content = event.getWireContent(); - const sender = event.getSender(); - const algorithm = content.algorithm; - const deviceKey = content.sender_key; - - // retry decryption for all events sent by the sender_key. This will - // update the events to show a message indicating that the olm session was - // wedged. - const retryDecryption = () => { - const roomDecryptors = this._getRoomDecryptors(olmlib.MEGOLM_ALGORITHM); - for (const decryptor of roomDecryptors) { - decryptor.retryDecryptionFromSender(deviceKey); - } - }; - - if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { - return; - } - - // check when we last forced a new session with this device: if we've already done so - // recently, don't do it again. - this._lastNewSessionForced[sender] = this._lastNewSessionForced[sender] || {}; - const lastNewSessionForced = this._lastNewSessionForced[sender][deviceKey] || 0; - if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) { - logger.debug( - "New session already forced with device " + sender + ":" + deviceKey + - " at " + lastNewSessionForced + ": not forcing another", - ); - await this._olmDevice.recordSessionProblem(deviceKey, "wedged", true); - retryDecryption(); - return; - } - - // establish a new olm session with this device since we're failing to decrypt messages - // on a current session. - // Note that an undecryptable message from another device could easily be spoofed - - // is there anything we can do to mitigate this? - let device = this._deviceList.getDeviceByIdentityKey(algorithm, deviceKey); - if (!device) { - // if we don't know about the device, fetch the user's devices again - // and retry before giving up - await this.downloadKeys([sender], false); - device = this._deviceList.getDeviceByIdentityKey(algorithm, deviceKey); - if (!device) { - logger.info( - "Couldn't find device for identity key " + deviceKey + - ": not re-establishing session", - ); - await this._olmDevice.recordSessionProblem(deviceKey, "wedged", false); - retryDecryption(); - return; - } - } - const devicesByUser = {}; - devicesByUser[sender] = [device]; - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, true, - ); - - this._lastNewSessionForced[sender][deviceKey] = Date.now(); - - // Now send a blank message on that session so the other side knows about it. - // (The keyshare request is sent in the clear so that won't do) - // We send this first such that, as long as the toDevice messages arrive in the - // same order we sent them, the other end will get this first, set up the new session, - // then get the keyshare request and send the key over this new session (because it - // is the session it has most recently received a message on). - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - sender, - device, - { type: "m.dummy" }, - ); - - await this._olmDevice.recordSessionProblem(deviceKey, "wedged", true); - retryDecryption(); - - await this._baseApis.sendToDevice("m.room.encrypted", { - [sender]: { - [device.deviceId]: encryptedContent, - }, - }); - - // Most of the time this probably won't be necessary since we'll have queued up a key request when - // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending - // it. This won't always be the case though so we need to re-send any that have already been sent - // to avoid races. - const requestsToResend = - await this._outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest( - sender, device.deviceId, - ); - for (const keyReq of requestsToResend) { - this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true); - } -}; - -/** - * Handle a change in the membership state of a member of a room - * - * @private - * @param {module:models/event.MatrixEvent} event event causing the change - * @param {module:models/room-member} member user whose membership changed - * @param {string=} oldMembership previous membership - */ -Crypto.prototype._onRoomMembership = function(event, member, oldMembership) { - // this event handler is registered on the *client* (as opposed to the room - // member itself), which means it is only called on changes to the *live* - // membership state (ie, it is not called when we back-paginate, nor when - // we load the state in the initialsync). - // - // Further, it is automatically registered and called when new members - // arrive in the room. - - const roomId = member.roomId; - - const alg = this._roomEncryptors[roomId]; - if (!alg) { - // not encrypting in this room - return; - } - // only mark users in this room as tracked if we already started tracking in this room - // this way we don't start device queries after sync on behalf of this room which we won't use - // the result of anyway, as we'll need to do a query again once all the members are fetched - // by calling _trackRoomDevices - if (this._roomDeviceTrackingState[roomId]) { - if (member.membership == 'join') { - logger.log('Join event for ' + member.userId + ' in ' + roomId); - // make sure we are tracking the deviceList for this user - this._deviceList.startTrackingDeviceList(member.userId); - } else if (member.membership == 'invite' && - this._clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) { - logger.log('Invite event for ' + member.userId + ' in ' + roomId); - this._deviceList.startTrackingDeviceList(member.userId); - } - } - - alg.onRoomMembership(event, member, oldMembership); -}; - -/** - * Called when we get an m.room_key_request event. - * - * @private - * @param {module:models/event.MatrixEvent} event key request event - */ -Crypto.prototype._onRoomKeyRequestEvent = function(event) { - const content = event.getContent(); - if (content.action === "request") { - // Queue it up for now, because they tend to arrive before the room state - // events at initial sync, and we want to see if we know anything about the - // room before passing them on to the app. - const req = new IncomingRoomKeyRequest(event); - this._receivedRoomKeyRequests.push(req); - } else if (content.action === "request_cancellation") { - const req = new IncomingRoomKeyRequestCancellation(event); - this._receivedRoomKeyRequestCancellations.push(req); - } -}; - -/** - * Process any m.room_key_request events which were queued up during the - * current sync. - * - * @private - */ -Crypto.prototype._processReceivedRoomKeyRequests = async function() { - if (this._processingRoomKeyRequests) { - // we're still processing last time's requests; keep queuing new ones - // up for now. - return; - } - this._processingRoomKeyRequests = true; - - try { - // we need to grab and clear the queues in the synchronous bit of this method, - // so that we don't end up racing with the next /sync. - const requests = this._receivedRoomKeyRequests; - this._receivedRoomKeyRequests = []; - const cancellations = this._receivedRoomKeyRequestCancellations; - this._receivedRoomKeyRequestCancellations = []; - - // Process all of the requests, *then* all of the cancellations. - // - // This makes sure that if we get a request and its cancellation in the - // same /sync result, then we process the request before the - // cancellation (and end up with a cancelled request), rather than the - // cancellation before the request (and end up with an outstanding - // request which should have been cancelled.) - await Promise.all(requests.map((req) => - this._processReceivedRoomKeyRequest(req))); - await Promise.all(cancellations.map((cancellation) => - this._processReceivedRoomKeyRequestCancellation(cancellation))); - } catch (e) { - logger.error(`Error processing room key requsts: ${e}`); - } finally { - this._processingRoomKeyRequests = false; - } -}; - -/** - * Helper for processReceivedRoomKeyRequests - * - * @param {IncomingRoomKeyRequest} req - */ -Crypto.prototype._processReceivedRoomKeyRequest = async function(req) { - const userId = req.userId; - const deviceId = req.deviceId; - - const body = req.requestBody; - const roomId = body.room_id; - const alg = body.algorithm; - - logger.log(`m.room_key_request from ${userId}:${deviceId}` + - ` for ${roomId} / ${body.session_id} (id ${req.requestId})`); - - if (userId !== this._userId) { - if (!this._roomEncryptors[roomId]) { - logger.debug(`room key request for unencrypted room ${roomId}`); - return; - } - const encryptor = this._roomEncryptors[roomId]; - const device = this._deviceList.getStoredDevice(userId, deviceId); - if (!device) { - logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); - return; - } - - try { - await encryptor.reshareKeyWithDevice( - body.sender_key, body.session_id, userId, device, - ); - } catch (e) { - logger.warn( - "Failed to re-share keys for session " + body.session_id + - " with device " + userId + ":" + device.deviceId, e, - ); - } - return; - } - - if (deviceId === this._deviceId) { - // We'll always get these because we send room key requests to - // '*' (ie. 'all devices') which includes the sending device, - // so ignore requests from ourself because apart from it being - // very silly, it won't work because an Olm session cannot send - // messages to itself. - // The log here is probably superfluous since we know this will - // always happen, but let's log anyway for now just in case it - // causes issues. - logger.log("Ignoring room key request from ourselves"); - return; - } - - // todo: should we queue up requests we don't yet have keys for, - // in case they turn up later? - - // if we don't have a decryptor for this room/alg, we don't have - // the keys for the requested events, and can drop the requests. - if (!this._roomDecryptors[roomId]) { - logger.log(`room key request for unencrypted room ${roomId}`); - return; - } - - const decryptor = this._roomDecryptors[roomId][alg]; - if (!decryptor) { - logger.log(`room key request for unknown alg ${alg} in room ${roomId}`); - return; - } - - if (!await decryptor.hasKeysForKeyRequest(req)) { - logger.log( - `room key request for unknown session ${roomId} / ` + - body.session_id, - ); - return; - } - - req.share = () => { - decryptor.shareKeysWithDevice(req); - }; - - // if the device is verified already, share the keys - if (this.checkDeviceTrust(userId, deviceId).isVerified()) { - logger.log('device is already verified: sharing keys'); - req.share(); - return; - } - - this.emit("crypto.roomKeyRequest", req); -}; - -/** - * Helper for processReceivedRoomKeyRequests - * - * @param {IncomingRoomKeyRequestCancellation} cancellation - */ -Crypto.prototype._processReceivedRoomKeyRequestCancellation = async function( - cancellation, -) { - logger.log( - `m.room_key_request cancellation for ${cancellation.userId}:` + - `${cancellation.deviceId} (id ${cancellation.requestId})`, - ); - - // we should probably only notify the app of cancellations we told it - // about, but we don't currently have a record of that, so we just pass - // everything through. - this.emit("crypto.roomKeyRequestCancellation", cancellation); -}; - -/** - * Get a decryptor for a given room and algorithm. - * - * If we already have a decryptor for the given room and algorithm, return - * it. Otherwise try to instantiate it. - * - * @private - * - * @param {string?} roomId room id for decryptor. If undefined, a temporary - * decryptor is instantiated. - * - * @param {string} algorithm crypto algorithm - * - * @return {module:crypto.algorithms.base.DecryptionAlgorithm} - * - * @raises {module:crypto.algorithms.DecryptionError} if the algorithm is - * unknown - */ -Crypto.prototype._getRoomDecryptor = function(roomId, algorithm) { - let decryptors; - let alg; - - roomId = roomId || null; - if (roomId) { - decryptors = this._roomDecryptors[roomId]; - if (!decryptors) { - this._roomDecryptors[roomId] = decryptors = {}; - } - - alg = decryptors[algorithm]; - if (alg) { - return alg; - } - } - - const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm]; - if (!AlgClass) { - throw new algorithms.DecryptionError( - 'UNKNOWN_ENCRYPTION_ALGORITHM', - 'Unknown encryption algorithm "' + algorithm + '".', - ); - } - alg = new AlgClass({ - userId: this._userId, - crypto: this, - olmDevice: this._olmDevice, - baseApis: this._baseApis, - roomId: roomId, - }); - - if (decryptors) { - decryptors[algorithm] = alg; - } - return alg; -}; - -/** - * Get all the room decryptors for a given encryption algorithm. - * - * @param {string} algorithm The encryption algorithm - * - * @return {array} An array of room decryptors - */ -Crypto.prototype._getRoomDecryptors = function(algorithm) { - const decryptors = []; - for (const d of Object.values(this._roomDecryptors)) { - if (algorithm in d) { - decryptors.push(d[algorithm]); - } - } - return decryptors; -}; - -/** - * sign the given object with our ed25519 key - * - * @param {Object} obj Object to which we will add a 'signatures' property - */ -Crypto.prototype._signObject = async function(obj) { - const sigs = obj.signatures || {}; - const unsigned = obj.unsigned; - - delete obj.signatures; - delete obj.unsigned; - - sigs[this._userId] = sigs[this._userId] || {}; - sigs[this._userId]["ed25519:" + this._deviceId] = - await this._olmDevice.sign(anotherjson.stringify(obj)); - obj.signatures = sigs; - if (unsigned !== undefined) obj.unsigned = unsigned; -}; - -/** - * The parameters of a room key request. The details of the request may - * vary with the crypto algorithm, but the management and storage layers for - * outgoing requests expect it to have 'room_id' and 'session_id' properties. - * - * @typedef {Object} RoomKeyRequestBody - */ - -/** - * Represents a received m.room_key_request event - * - * @property {string} userId user requesting the key - * @property {string} deviceId device requesting the key - * @property {string} requestId unique id for the request - * @property {module:crypto~RoomKeyRequestBody} requestBody - * @property {function()} share callback which, when called, will ask - * the relevant crypto algorithm implementation to share the keys for - * this request. - */ -class IncomingRoomKeyRequest { - constructor(event) { - const content = event.getContent(); - - this.userId = event.getSender(); - this.deviceId = content.requesting_device_id; - this.requestId = content.request_id; - this.requestBody = content.body || {}; - this.share = () => { - throw new Error("don't know how to share keys for this request yet"); - }; - } -} - -/** - * Represents a received m.room_key_request cancellation - * - * @property {string} userId user requesting the cancellation - * @property {string} deviceId device requesting the cancellation - * @property {string} requestId unique id for the request to be cancelled - */ -class IncomingRoomKeyRequestCancellation { - constructor(event) { - const content = event.getContent(); - - this.userId = event.getSender(); - this.deviceId = content.requesting_device_id; - this.requestId = content.request_id; - } -} - -/** - * The result of a (successful) call to decryptEvent. - * - * @typedef {Object} EventDecryptionResult - * - * @property {Object} clearEvent The plaintext payload for the event - * (typically containing type and content fields). - * - * @property {?string} senderCurve25519Key Key owned by the sender of this - * event. See {@link module:models/event.MatrixEvent#getSenderKey}. - * - * @property {?string} claimedEd25519Key ed25519 key claimed by the sender of - * this event. See - * {@link module:models/event.MatrixEvent#getClaimedEd25519Key}. - * - * @property {?Array} forwardingCurve25519KeyChain list of curve25519 - * keys involved in telling us about the senderCurve25519Key and - * claimedEd25519Key. See - * {@link module:models/event.MatrixEvent#getForwardingCurve25519KeyChain}. - */ - -/** - * Fires when we receive a room key request - * - * @event module:client~MatrixClient#"crypto.roomKeyRequest" - * @param {module:crypto~IncomingRoomKeyRequest} req request details - */ - -/** - * Fires when we receive a room key request cancellation - * - * @event module:client~MatrixClient#"crypto.roomKeyRequestCancellation" - * @param {module:crypto~IncomingRoomKeyRequestCancellation} req - */ - -/** - * Fires when the app may wish to warn the user about something related - * the end-to-end crypto. - * - * @event module:client~MatrixClient#"crypto.warning" - * @param {string} type One of the strings listed above - */ diff --git a/src/crypto/index.ts b/src/crypto/index.ts new file mode 100644 index 000000000..e3ab46e6c --- /dev/null +++ b/src/crypto/index.ts @@ -0,0 +1,3745 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018-2019 New Vector Ltd +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. +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 + */ + +import anotherjson from "another-json"; +import { EventEmitter } from 'events'; + +import { ReEmitter } from '../ReEmitter'; +import { logger } from '../logger'; +import { OlmDevice } from "./OlmDevice"; +import * as olmlib from "./olmlib"; +import { DeviceList } from "./DeviceList"; +import { DeviceInfo } from "./deviceinfo"; +import * as algorithms from "./algorithms"; +import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from './CrossSigning'; +import { EncryptionSetupBuilder } from "./EncryptionSetup"; +import { SECRET_STORAGE_ALGORITHM_V1_AES, SecretStorage } from './SecretStorage'; +import { OutgoingRoomKeyRequestManager } from './OutgoingRoomKeyRequestManager'; +import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { ReciprocateQRCode, SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD } from './verification/QRCode'; +import { SAS } from './verification/SAS'; +import { keyFromPassphrase } from './key_passphrase'; +import { decodeRecoveryKey, encodeRecoveryKey } from './recoverykey'; +import { VerificationRequest } from "./verification/request/VerificationRequest"; +import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel"; +import { ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel"; +import { IllegalMethod } from "./verification/IllegalMethod"; +import { KeySignatureUploadError } from "../errors"; +import { decryptAES, encryptAES } from './aes'; +import { DehydrationManager } from './dehydration'; +import { BackupManager } from "./backup"; +import { IStore } from "../store"; +import { Room, RoomMember, MatrixEvent, MatrixClient, IKeysUploadResponse } from ".."; +import type { EncryptionAlgorithm, DecryptionAlgorithm } from "./algorithms/base"; +import type { RoomList } from "./RoomList"; +import { IRecoveryKey, IEncryptedEventInfo } from "./api"; + +const DeviceVerification = DeviceInfo.DeviceVerification; + +const defaultVerificationMethods = { + [ReciprocateQRCode.NAME]: ReciprocateQRCode, + [SAS.NAME]: SAS, + + // These two can't be used for actual verification, but we do + // need to be able to define them here for the verification flows + // to start. + [SHOW_QR_CODE_METHOD]: IllegalMethod, + [SCAN_QR_CODE_METHOD]: IllegalMethod, +} + +/** + * verification method names + */ +export const verificationMethods = { + RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME, + SAS: SAS.NAME, +} + +export function isCryptoAvailable(): boolean { + return Boolean(global.Olm); +} + +const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; + +interface IInitOpts { + exportedOlmDevice?: any; // TODO types + pickleKey?: string; +} + +export interface IBootstrapCrossSigningOpts { + setupNewCrossSigning?: boolean; + authUploadDeviceSigningKeys?(makeRequest: (authData: any) => void): Promise; +} + +interface IBootstrapSecretStorageOpts { + keyBackupInfo?: any; // TODO types + setupNewKeyBackup?: boolean; + setupNewSecretStorage?: boolean; + createSecretStorageKey?(): Promise<{ + keyInfo?: any; // TODO types + privateKey?: Uint8Array; + }>; + getKeyBackupPassphrase?(): Promise; +} + +/* eslint-disable camelcase */ +interface IRoomKey { + room_id: string; + algorithm: string; +} + +interface IRoomKeyRequestBody extends IRoomKey { + session_id: string; + sender_key: string +} + +interface IMegolmSessionData { + sender_key: string; + forwarding_curve25519_key_chain: string[]; + sender_claimed_keys: Record; + room_id: string; + session_id: string; + session_key: string; +} +/* eslint-enable camelcase */ + +interface IDeviceVerificationUpgrade { + devices: DeviceInfo[]; + crossSigningInfo: CrossSigningInfo; +} + +/** + * @typedef {Object} module:crypto~OlmSessionResult + * @property {module:crypto/deviceinfo} device device info + * @property {string?} sessionId base64 olm session id; null if no session + * could be established + */ + +interface IOlmSessionResult { + device: DeviceInfo; + sessionId?: string; +} + +interface IUserOlmSession { + deviceIdKey: string; + sessions: { + sessionId: string; + hasReceivedMessage: boolean; + }[]; +} + +interface ISyncData { + oldSyncToken?: string; + nextSyncToken: string; + catchingUp?: boolean; +} + +interface ISyncDeviceLists { + changed: string[]; + left: string[]; +} + +interface IRoomKeyRequestRecipient { + userId: string; + deviceId: string; +} + +interface ISignableObject { + signatures?: object; + unsigned?: object +} + +interface IEventDecryptionResult { + clearEvent: object; + senderCurve25519Key?: string; + claimedEd25519Key?: string; + forwardingCurve25519KeyChain?: string[]; + untrusted?: boolean; +} + +export class Crypto extends EventEmitter { + /** + * @return {string} The version of Olm. + */ + static getOlmVersion(): string { + return OlmDevice.getOlmVersion(); + } + + public readonly backupManager: BackupManager; + public readonly crossSigningInfo: CrossSigningInfo; + public readonly olmDevice: OlmDevice; + public readonly deviceList: DeviceList; + public readonly dehydrationManager: DehydrationManager; + public readonly secretStorage: SecretStorage; + + private readonly reEmitter: ReEmitter; + private readonly verificationMethods: any; // TODO types + private readonly supportedAlgorithms: DecryptionAlgorithm[]; + private readonly outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager; + private readonly toDeviceVerificationRequests: ToDeviceRequests; + private readonly inRoomVerificationRequests: InRoomRequests; + + private trustCrossSignedDevices = true; + // the last time we did a check for the number of one-time-keys on the server. + private lastOneTimeKeyCheck: number = null; + private oneTimeKeyCheckInProgress = false; + + // EncryptionAlgorithm instance for each room + private roomEncryptors: Record = {}; + // map from algorithm to DecryptionAlgorithm instance, for each room + private roomDecryptors: Record> = {}; + + private deviceKeys: Record = {}; // type: key + + private globalBlacklistUnverifiedDevices = false; + private globalErrorOnUnknownDevices = true; + + // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations + // we received in the current sync. + private receivedRoomKeyRequests: IncomingRoomKeyRequest[] = []; + private receivedRoomKeyRequestCancellations: IncomingRoomKeyRequestCancellation[] = []; + // true if we are currently processing received room key requests + private processingRoomKeyRequests = false; + // controls whether device tracking is delayed + // until calling encryptEvent or trackRoomDevices, + // or done immediately upon enabling room encryption. + private lazyLoadMembers = false; + // in case lazyLoadMembers is true, + // track if an initial tracking of all the room members + // has happened for a given room. This is delayed + // to avoid loading room members as long as possible. + private roomDeviceTrackingState: Record> = {}; // roomId: Promise> = {}; + + // This flag will be unset whilst the client processes a sync response + // so that we don't start requesting keys until we've actually finished + // processing the response. + private sendKeyRequestsImmediately = false; + + private oneTimeKeyCount: number; + private needsNewFallback: boolean; + + /** + * Cryptography bits + * + * This module is internal to the js-sdk; the public API is via MatrixClient. + * + * @constructor + * @alias module:crypto + * + * @internal + * + * @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 + * + * @param {string} userId The user ID for the local user + * + * @param {string} deviceId The identifier for this device. + * + * @param {Object} clientStore the MatrixClient data store. + * + * @param {module:crypto/store/base~CryptoStore} cryptoStore + * storage for the crypto layer. + * + * @param {RoomList} roomList An initialised RoomList object + * + * @param {Array} verificationMethods Array of verification methods to use. + * Each element can either be a string from MatrixClient.verificationMethods + * or a class that implements a verification method. + */ + constructor( + private readonly baseApis: MatrixClient, + public readonly sessionStore: any, // TODO types + private readonly userId: string, + private readonly deviceId: string, + private readonly clientStore: IStore, + public readonly cryptoStore: any, // TODO types + private readonly roomList: RoomList, + verificationMethods: any[], // TODO types + ) { + super(); + this.reEmitter = new ReEmitter(this); + + if (verificationMethods) { + this.verificationMethods = new Map(); + for (const method of verificationMethods) { + if (typeof method === "string") { + if (defaultVerificationMethods[method]) { + this.verificationMethods.set( + method, + defaultVerificationMethods[method], + ); + } + } else if (method.NAME) { + this.verificationMethods.set( + method.NAME, + method, + ); + } else { + logger.warn(`Excluding unknown verification method ${method}`); + } + } + } else { + this.verificationMethods = defaultVerificationMethods; + } + + this.backupManager = new BackupManager(baseApis, async () => { + // 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.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(); + } + + throw new Error("Unable to get private key"); + }); + + this.olmDevice = new OlmDevice(cryptoStore); + this.deviceList = new DeviceList(baseApis, cryptoStore, this.olmDevice); + + // XXX: This isn't removed at any point, but then none of the event listeners + // this class sets seem to be removed at any point... :/ + this.deviceList.on('userCrossSigningUpdated', this.onDeviceListUserCrossSigningUpdated); + this.reEmitter.reEmit(this.deviceList, ["crypto.devicesUpdated", "crypto.willUpdateDevices"]); + + this.supportedAlgorithms = Object.keys(algorithms.DECRYPTION_CLASSES); + + this.outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager( + baseApis, this.deviceId, this.cryptoStore, + ); + + this.toDeviceVerificationRequests = new ToDeviceRequests(); + this.inRoomVerificationRequests = new InRoomRequests(); + + const cryptoCallbacks = this.baseApis.cryptoCallbacks || {}; + const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this.olmDevice); + + this.crossSigningInfo = new CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks); + this.secretStorage = new SecretStorage(baseApis, cryptoCallbacks); + this.dehydrationManager = new DehydrationManager(this); + + // Assuming no app-supplied callback, default to getting from SSSS. + if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) { + cryptoCallbacks.getCrossSigningKey = async (type) => { + return CrossSigningInfo.getFromSecretStorage(type, this.secretStorage); + }; + } + } + + /** + * Initialise the crypto module so that it is ready for use + * + * Returns a promise which resolves once the crypto module is ready for use. + * + * @param {Object} opts keyword arguments. + * @param {string} opts.exportedOlmDevice (Optional) data from exported device + * that must be re-created. + */ + public async init({ exportedOlmDevice, pickleKey }: IInitOpts = {}): Promise { + logger.log("Crypto: initialising Olm..."); + await global.Olm.init(); + logger.log(exportedOlmDevice + ? "Crypto: initialising Olm device from exported device..." + : "Crypto: initialising Olm device...", + ); + await this.olmDevice.init({ fromExportedDevice: exportedOlmDevice, pickleKey }); + logger.log("Crypto: loading device list..."); + await this.deviceList.load(); + + // build our device keys: these will later be uploaded + this.deviceKeys["ed25519:" + this.deviceId] = this.olmDevice.deviceEd25519Key; + this.deviceKeys["curve25519:" + this.deviceId] = this.olmDevice.deviceCurve25519Key; + + logger.log("Crypto: fetching own devices..."); + let myDevices = this.deviceList.getRawStoredDevicesForUser(this.userId); + + if (!myDevices) { + myDevices = {}; + } + + if (!myDevices[this.deviceId]) { + // add our own deviceinfo to the cryptoStore + logger.log("Crypto: adding this device to the store..."); + const deviceInfo = { + keys: this.deviceKeys, + algorithms: this.supportedAlgorithms, + verified: DeviceVerification.VERIFIED, + known: true, + }; + + myDevices[this.deviceId] = deviceInfo; + this.deviceList.storeDevicesForUser( + this.userId, myDevices, + ); + this.deviceList.saveIfDirty(); + } + + await this.cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this.cryptoStore.getCrossSigningKeys(txn, (keys) => { + // can be an empty object after resetting cross-signing keys, see storeTrustedSelfKeys + if (keys && Object.keys(keys).length !== 0) { + logger.log("Loaded cross-signing public keys from crypto store"); + this.crossSigningInfo.setKeys(keys); + } + }); + }, + ); + // make sure we are keeping track of our own devices + // (this is important for key backups & things) + this.deviceList.startTrackingDeviceList(this.userId); + + logger.log("Crypto: checking for key backup..."); + this.backupManager.checkAndStart(); + } + + /** + * Whether to trust a others users signatures of their devices. + * If false, devices will only be considered 'verified' if we have + * verified that device individually (effectively disabling cross-signing). + * + * Default: true + * + * @return {boolean} True if trusting cross-signed devices + */ + public getCryptoTrustCrossSignedDevices(): boolean { + return this.trustCrossSignedDevices; + } + + /** + * See getCryptoTrustCrossSignedDevices + + * This may be set before initCrypto() is called to ensure no races occur. + * + * @param {boolean} val True to trust cross-signed devices + */ + public setCryptoTrustCrossSignedDevices(val: boolean): void { + this.trustCrossSignedDevices = val; + + for (const userId of this.deviceList.getKnownUserIds()) { + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + for (const deviceId of Object.keys(devices)) { + const deviceTrust = this.checkDeviceTrust(userId, deviceId); + // If the device is locally verified then isVerified() is always true, + // so this will only have caused the value to change if the device is + // cross-signing verified but not locally verified + if ( + !deviceTrust.isLocallyVerified() && + deviceTrust.isCrossSigningVerified() + ) { + const deviceObj = this.deviceList.getStoredDevice(userId, deviceId); + this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); + } + } + } + } + + /** + * Create a recovery key from a user-supplied passphrase. + * + * @param {string} password Passphrase string that can be entered by the user + * when restoring the backup as an alternative to entering the recovery key. + * Optional. + * @returns {Promise} 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. + */ + public async createRecoveryKeyFromPassphrase(password: string): Promise { + const decryption = new global.Olm.PkDecryption(); + try { + const keyInfo: Partial = {}; + if (password) { + const derivation = await keyFromPassphrase(password); + keyInfo.passphrase = { + algorithm: "m.pbkdf2", + iterations: derivation.iterations, + salt: derivation.salt, + }; + keyInfo.pubkey = decryption.init_with_private_key(derivation.key); + } else { + keyInfo.pubkey = decryption.generate_key(); + } + const privateKey = decryption.get_private_key(); + const encodedPrivateKey = encodeRecoveryKey(privateKey); + return { + keyInfo: keyInfo as IRecoveryKey["keyInfo"], + encodedPrivateKey, + privateKey, + }; + } finally { + if (decryption) decryption.free(); + } + } + + /** + * Checks whether cross signing: + * - is enabled on this account and trusted by this device + * - has private keys either cached locally or stored in secret storage + * + * If this function returns false, bootstrapCrossSigning() can be used + * to fix things such that it returns true. That is to say, after + * bootstrapCrossSigning() completes successfully, this function should + * return true. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @return {boolean} True if cross-signing is ready to be used on this device + */ + public async isCrossSigningReady(): Promise { + const publicKeysOnDevice = this.crossSigningInfo.getId(); + const privateKeysExistSomewhere = ( + await this.crossSigningInfo.isStoredInKeyCache() || + await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage) + ); + + return !!(publicKeysOnDevice && privateKeysExistSomewhere); + } + + /** + * Checks whether secret storage: + * - is enabled on this account + * - is storing cross-signing private keys + * - is storing session backup key (if enabled) + * + * If this function returns false, bootstrapSecretStorage() can be used + * to fix things such that it returns true. That is to say, after + * bootstrapSecretStorage() completes successfully, this function should + * return true. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @return {boolean} True if secret storage is ready to be used on this device + */ + public async isSecretStorageReady(): Promise { + const secretStorageKeyInAccount = await this.secretStorage.hasKey(); + const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage( + this.secretStorage, + ); + const sessionBackupInStorage = ( + !this.backupManager.getKeyBackupEnabled() || + this.baseApis.isKeyBackupKeyStored() + ); + + return !!( + secretStorageKeyInAccount && + privateKeysInStorage && + sessionBackupInStorage + ); + } + + /** + * Bootstrap cross-signing by creating keys if needed. If everything is already + * set up, then no changes are made, so this is safe to run to ensure + * cross-signing is ready for use. + * + * This function: + * - creates new cross-signing keys if they are not found locally cached nor in + * secret storage (if it has been setup) + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param {function} opts.authUploadDeviceSigningKeys Function + * called to await an interactive auth flow when uploading device signing keys. + * @param {boolean} [opts.setupNewCrossSigning] Optional. Reset even if keys + * already exist. + * Args: + * {function} A function that makes the request requiring auth. Receives the + * auth data as an object. Can be called multiple times, first with an empty + * authDict, to obtain the flows. + */ + public async bootstrapCrossSigning({ + authUploadDeviceSigningKeys, + setupNewCrossSigning, + }: IBootstrapCrossSigningOpts = {}): Promise { + logger.log("Bootstrapping cross-signing"); + + const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; + const builder = new EncryptionSetupBuilder( + this.baseApis.store.accountData, + delegateCryptoCallbacks, + ); + const crossSigningInfo = new CrossSigningInfo( + this.userId, + builder.crossSigningCallbacks, + builder.crossSigningCallbacks, + ); + + // Reset the cross-signing keys + const resetCrossSigning = async () => { + crossSigningInfo.resetKeys(); + // Sign master key with device key + await this.signObject(crossSigningInfo.keys.master); + + // Store auth flow helper function, as we need to call it when uploading + // to ensure we handle auth errors properly. + builder.addCrossSigningKeys(authUploadDeviceSigningKeys, crossSigningInfo.keys); + + // Cross-sign own device + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); + const deviceSignature = await crossSigningInfo.signDevice(this.userId, device); + builder.addKeySignature(this.userId, this.deviceId, deviceSignature); + + // Sign message key backup with cross-signing master key + if (this.backupManager.backupInfo) { + await crossSigningInfo.signObject( + this.backupManager.backupInfo.auth_data, "master", + ); + builder.addSessionBackup(this.backupManager.backupInfo); + } + }; + + const publicKeysOnDevice = this.crossSigningInfo.getId(); + const privateKeysInCache = await this.crossSigningInfo.isStoredInKeyCache(); + const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage( + this.secretStorage, + ); + const privateKeysExistSomewhere = ( + privateKeysInCache || + privateKeysInStorage + ); + + // Log all relevant state for easier parsing of debug logs. + logger.log({ + setupNewCrossSigning, + publicKeysOnDevice, + privateKeysInCache, + privateKeysInStorage, + privateKeysExistSomewhere, + }); + + if (!privateKeysExistSomewhere || setupNewCrossSigning) { + logger.log( + "Cross-signing private keys not found locally or in secret storage, " + + "creating new keys", + ); + // If a user has multiple devices, it important to only call bootstrap + // as part of some UI flow (and not silently during startup), as they + // may have setup cross-signing on a platform which has not saved keys + // to secret storage, and this would reset them. In such a case, you + // should prompt the user to verify any existing devices first (and + // request private keys from those devices) before calling bootstrap. + await resetCrossSigning(); + } else if (publicKeysOnDevice && privateKeysInCache) { + logger.log( + "Cross-signing public keys trusted and private keys found locally", + ); + } else if (privateKeysInStorage) { + logger.log( + "Cross-signing private keys not found locally, but they are available " + + "in secret storage, reading storage and caching locally", + ); + await this.checkOwnCrossSigningTrust({ + allowPrivateKeyRequests: true, + }); + } + + // Assuming no app-supplied callback, default to storing new private keys in + // secret storage if it exists. If it does not, it is assumed this will be + // done as part of setting up secret storage later. + const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys; + if ( + crossSigningPrivateKeys.size && + !this.baseApis.cryptoCallbacks.saveCrossSigningKeys + ) { + const secretStorage = new SecretStorage( + builder.accountDataClientAdapter, + builder.ssssCryptoCallbacks); + if (await secretStorage.hasKey()) { + logger.log("Storing new cross-signing private keys in secret storage"); + // This is writing to in-memory account data in + // builder.accountDataClientAdapter so won't fail + await CrossSigningInfo.storeInSecretStorage( + crossSigningPrivateKeys, + secretStorage, + ); + } + } + + const operation = builder.buildOperation(); + await operation.apply(this); + // This persists private keys and public keys as trusted, + // only do this if apply succeeded for now as retry isn't in place yet + await builder.persist(this); + + logger.log("Cross-signing ready"); + } + + /** + * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is + * already set up, then no changes are made, so this is safe to run to ensure secret + * storage is ready for use. + * + * This function + * - creates a new Secure Secret Storage key if no default key exists + * - if a key backup exists, it is migrated to store the key in the Secret + * Storage + * - creates a backup if none exists, and one is requested + * - migrates Secure Secret Storage to use the latest algorithm, if an outdated + * algorithm is found + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param {function} [opts.createSecretStorageKey] Optional. Function + * called to await a secret storage key creation flow. + * Returns: + * {Promise} 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. + * @param {object} [opts.keyBackupInfo] The current key backup object. If passed, + * the passphrase and recovery key from this backup will be used. + * @param {boolean} [opts.setupNewKeyBackup] 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. + * @param {boolean} [opts.setupNewSecretStorage] Optional. Reset even if keys already exist. + * @param {func} [opts.getKeyBackupPassphrase] Optional. Function called to get the user's + * current key backup passphrase. Should return a promise that resolves with a Buffer + * containing the key, or rejects if the key cannot be obtained. + * Returns: + * {Promise} A promise which resolves to key creation data for + * SecretStorage#addKey: an object with `passphrase` etc fields. + */ + // TODO this does not resolve with what it says it does + public async bootstrapSecretStorage({ + createSecretStorageKey = async () => ({ }), + keyBackupInfo, + setupNewKeyBackup, + setupNewSecretStorage, + getKeyBackupPassphrase, + }: IBootstrapSecretStorageOpts = {}) { + logger.log("Bootstrapping Secure Secret Storage"); + const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; + const builder = new EncryptionSetupBuilder( + this.baseApis.store.accountData, + delegateCryptoCallbacks, + ); + const secretStorage = new SecretStorage( + builder.accountDataClientAdapter, + builder.ssssCryptoCallbacks, + ); + + // the ID of the new SSSS key, if we create one + let newKeyId = null; + + // create a new SSSS key and set it as default + const createSSSS = async (opts, privateKey: Uint8Array) => { + opts = opts || {}; + if (privateKey) { + opts.key = privateKey; + } + + const { keyId, keyInfo } = await secretStorage.addKey(SECRET_STORAGE_ALGORITHM_V1_AES, opts); + + if (privateKey) { + // make the private key available to encrypt 4S secrets + builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); + } + + await secretStorage.setDefaultKeyId(keyId); + return keyId; + }; + + const ensureCanCheckPassphrase = async (keyId, keyInfo) => { + if (!keyInfo.mac) { + const key = await this.baseApis.cryptoCallbacks.getSecretStorageKey( + { keys: { [keyId]: keyInfo } }, "", + ); + if (key) { + const privateKey = key[1]; + builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); + const { iv, mac } = await SecretStorage._calculateKeyCheck(privateKey); + keyInfo.iv = iv; + keyInfo.mac = mac; + + await builder.setAccountData( + `m.secret_storage.key.${keyId}`, keyInfo, + ); + } + } + }; + + const signKeyBackupWithCrossSigning = async (keyBackupAuthData) => { + if ( + this.crossSigningInfo.getId() && + await this.crossSigningInfo.isStoredInKeyCache("master") + ) { + try { + logger.log("Adding cross-signing signature to key backup"); + await this.crossSigningInfo.signObject(keyBackupAuthData, "master"); + } catch (e) { + // This step is not critical (just helpful), so we catch here + // and continue if it fails. + logger.error("Signing key backup with cross-signing keys failed", e); + } + } else { + logger.warn( + "Cross-signing keys not available, skipping signature on key backup", + ); + } + }; + + const oldSSSSKey = await this.getSecretStorageKey(); + const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null]; + const storageExists = ( + !setupNewSecretStorage && + oldKeyInfo && + oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES + ); + + // Log all relevant state for easier parsing of debug logs. + logger.log({ + keyBackupInfo, + setupNewKeyBackup, + setupNewSecretStorage, + storageExists, + oldKeyInfo, + }); + + if (!storageExists && !keyBackupInfo) { + // either we don't have anything, or we've been asked to restart + // from scratch + logger.log( + "Secret storage does not exist, creating new storage key", + ); + + // if we already have a usable default SSSS key and aren't resetting + // SSSS just use it. otherwise, create a new one + // Note: we leave the old SSSS key in place: there could be other + // secrets using it, in theory. We could move them to the new key but a) + // that would mean we'd need to prompt for the old passphrase, and b) + // it's not clear that would be the right thing to do anyway. + const { keyInfo, privateKey } = await createSecretStorageKey(); + newKeyId = await createSSSS(keyInfo, privateKey); + } else if (!storageExists && keyBackupInfo) { + // we have an existing backup, but no SSSS + logger.log("Secret storage does not exist, using key backup key"); + + // if we have the backup key already cached, use it; otherwise use the + // callback to prompt for the key + const backupKey = await this.getSessionBackupPrivateKey() || await getKeyBackupPassphrase(); + + // create a new SSSS key and use the backup key as the new SSSS key + const opts: any = {}; // TODO types + + if ( + 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, + salt: keyBackupInfo.auth_data.private_key_salt, + bits: 256, + }; + } + + newKeyId = await createSSSS(opts, backupKey); + + // store the backup key in secret storage + await secretStorage.store( + "m.megolm_backup.v1", olmlib.encodeBase64(backupKey), [newKeyId], + ); + + // 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 signKeyBackupWithCrossSigning(keyBackupInfo.auth_data); + + builder.addSessionBackup(keyBackupInfo); + } else { + // 4S is already set up + logger.log("Secret storage exists"); + + if (oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + // make sure that the default key has the information needed to + // check the passphrase + await ensureCanCheckPassphrase(oldKeyId, oldKeyInfo); + } + } + + // If we have cross-signing private keys cached, store them in secret + // storage if they are not there already. + if ( + !this.baseApis.cryptoCallbacks.saveCrossSigningKeys && + await this.isCrossSigningReady() && + (newKeyId || !await this.crossSigningInfo.isStoredInSecretStorage(secretStorage)) + ) { + logger.log("Copying cross-signing private keys from cache to secret storage"); + const crossSigningPrivateKeys = + await this.crossSigningInfo.getCrossSigningKeysFromCache(); + // This is writing to in-memory account data in + // builder.accountDataClientAdapter so won't fail + await CrossSigningInfo.storeInSecretStorage( + crossSigningPrivateKeys, + secretStorage, + ); + } + + if (setupNewKeyBackup && !keyBackupInfo) { + logger.log("Creating new message key backup version"); + const info = await this.baseApis.prepareKeyBackupVersion( + null /* random key */, + // don't write to secret storage, as it will write to this.secretStorage. + // Here, we want to capture all the side-effects of bootstrapping, + // and want to write to the local secretStorage object + { secureSecretStorage: false }, + ); + // write the key ourselves to 4S + const privateKey = decodeRecoveryKey(info.recovery_key); + await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey)); + + // create keyBackupInfo object to add to builder + const data = { + algorithm: info.algorithm, + auth_data: info.auth_data, + }; + + // Sign with cross-signing master key + await signKeyBackupWithCrossSigning(data.auth_data); + + // sign with the device fingerprint + await this.signObject(data.auth_data); + + builder.addSessionBackup(data); + } + + // Cache the session backup key + const sessionBackupKey = await secretStorage.get('m.megolm_backup.v1'); + if (sessionBackupKey) { + logger.info("Got session backup key from secret storage: caching"); + // fix up the backup key if it's in the wrong format, and replace + // in secret storage + const fixedBackupKey = fixBackupKey(sessionBackupKey); + if (fixedBackupKey) { + await secretStorage.store("m.megolm_backup.v1", + fixedBackupKey, [newKeyId || oldKeyId], + ); + } + const decodedBackupKey = new Uint8Array(olmlib.decodeBase64( + fixedBackupKey || sessionBackupKey, + )); + await builder.addSessionBackupPrivateKeyToCache(decodedBackupKey); + } + + const operation = builder.buildOperation(); + await operation.apply(this); + // this persists private keys and public keys as trusted, + // only do this if apply succeeded for now as retry isn't in place yet + await builder.persist(this); + + logger.log("Secure Secret Storage ready"); + } + + public addSecretStorageKey(algorithm: string, opts: any, keyID: string): any { // TODO types + return this.secretStorage.addKey(algorithm, opts, keyID); + } + + public hasSecretStorageKey(keyID: string): boolean { + return this.secretStorage.hasKey(keyID); + } + + public getSecretStorageKey(keyID?: string): any { // TODO types + return this.secretStorage.getKey(keyID); + } + + public storeSecret(name: string, secret: string, keys?: string[]): Promise { + return this.secretStorage.store(name, secret, keys); + } + + public getSecret(name: string): Promise { + return this.secretStorage.get(name); + } + + public isSecretStored(name: string, checkKey?: boolean): any { // TODO types + return this.secretStorage.isStored(name, checkKey); + } + + public requestSecret(name: string, devices: string[]): Promise { // TODO types + if (!devices) { + devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(this.userId)); + } + return this.secretStorage.request(name, devices); + } + + public getDefaultSecretStorageKeyId(): Promise { + return this.secretStorage.getDefaultKeyId(); + } + + public setDefaultSecretStorageKeyId(k: string): Promise { + return this.secretStorage.setDefaultKeyId(k); + } + + public checkSecretStorageKey(key: string, info: any): Promise { // TODO types + return this.secretStorage.checkKey(key, info); + } + + /** + * Checks that a given secret storage private key matches a given public key. + * This can be used by the getSecretStorageKey callback to verify that the + * private key it is about to supply is the one that was requested. + * + * @param {Uint8Array} privateKey The private key + * @param {string} expectedPublicKey The public key + * @returns {boolean} true if the key matches, otherwise false + */ + public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { + let decryption = null; + try { + decryption = new global.Olm.PkDecryption(); + const gotPubkey = decryption.init_with_private_key(privateKey); + // make sure it agrees with the given pubkey + return gotPubkey === expectedPublicKey; + } finally { + if (decryption) decryption.free(); + } + } + + /** + * Fetches the backup private key, if cached + * @returns {Promise} the key, if any, or null + */ + public async getSessionBackupPrivateKey(): Promise { + let key = await new Promise((resolve) => { // TODO types + this.cryptoStore.doTxn( + 'readonly', + [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this.cryptoStore.getSecretStorePrivateKey( + txn, + resolve, + "m.megolm_backup.v1", + ); + }, + ); + }); + + // make sure we have a Uint8Array, rather than a string + if (key && typeof key === "string") { + key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key)); + await this.storeSessionBackupPrivateKey(key); + } + if (key && key.ciphertext) { + const pickleKey = Buffer.from(this.olmDevice._pickleKey); + const decrypted = await decryptAES(key, pickleKey, "m.megolm_backup.v1"); + key = olmlib.decodeBase64(decrypted); + } + return key; + } + + /** + * Stores the session backup key to the cache + * @param {Uint8Array} key the private key + * @returns {Promise} so you can catch failures + */ + public async storeSessionBackupPrivateKey(key: Uint8Array): Promise { + if (!(key instanceof Uint8Array)) { + throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`); + } + const pickleKey = Buffer.from(this.olmDevice._pickleKey); + key = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); + return this.cryptoStore.doTxn( + 'readwrite', + [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", key); + }, + ); + } + + /** + * Checks that a given cross-signing private key matches a given public key. + * This can be used by the getCrossSigningKey callback to verify that the + * private key it is about to supply is the one that was requested. + * + * @param {Uint8Array} privateKey The private key + * @param {string} expectedPublicKey The public key + * @returns {boolean} true if the key matches, otherwise false + */ + public checkCrossSigningPrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { + let signing = null; + try { + signing = new global.Olm.PkSigning(); + const gotPubkey = signing.init_with_seed(privateKey); + // make sure it agrees with the given pubkey + return gotPubkey === expectedPublicKey; + } finally { + if (signing) signing.free(); + } + } + + /** + * Run various follow-up actions after cross-signing keys have changed locally + * (either by resetting the keys for the account or by getting them from secret + * storage), such as signing the current device, upgrading device + * verifications, etc. + */ + private async afterCrossSigningLocalKeyChange(): Promise { + logger.info("Starting cross-signing key change post-processing"); + + // sign the current device with the new key, and upload to the server + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); + const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device); + logger.info(`Starting background key sig upload for ${this.deviceId}`); + + const upload = ({ shouldEmit }) => { + return this.baseApis.uploadKeySignatures({ + [this.userId]: { + [this.deviceId]: signedDevice, + }, + }).then((response) => { + const { failures } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + "crypto.keySignatureUploadFailure", + failures, + "afterCrossSigningLocalKeyChange", + upload, // continuation + ); + } + throw new KeySignatureUploadError("Key upload failed", { failures }); + } + logger.info(`Finished background key sig upload for ${this.deviceId}`); + }).catch(e => { + logger.error( + `Error during background key sig upload for ${this.deviceId}`, + e, + ); + }); + }; + upload({ shouldEmit: true }); + + const shouldUpgradeCb = ( + this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications + ); + if (shouldUpgradeCb) { + logger.info("Starting device verification upgrade"); + + // Check all users for signatures if upgrade callback present + // FIXME: do this in batches + const users = {}; + for (const [userId, crossSigningInfo] + of Object.entries(this.deviceList._crossSigningInfo)) { + const upgradeInfo = await this.checkForDeviceVerificationUpgrade( + userId, CrossSigningInfo.fromStorage(crossSigningInfo, userId), + ); + if (upgradeInfo) { + users[userId] = upgradeInfo; + } + } + + if (Object.keys(users).length > 0) { + logger.info(`Found ${Object.keys(users).length} verif users to upgrade`); + try { + const usersToUpgrade = await shouldUpgradeCb({ users: users }); + if (usersToUpgrade) { + for (const userId of usersToUpgrade) { + if (userId in users) { + await this.baseApis.setDeviceVerified( + userId, users[userId].crossSigningInfo.getId(), + ); + } + } + } + } catch (e) { + logger.log( + "shouldUpgradeDeviceVerifications threw an error: not upgrading", e, + ); + } + } + + logger.info("Finished device verification upgrade"); + } + + logger.info("Finished cross-signing key change post-processing"); + } + + /** + * Check if a user's cross-signing key is a candidate for upgrading from device + * verification. + * + * @param {string} userId the user whose cross-signing information is to be checked + * @param {object} crossSigningInfo the cross-signing information to check + */ + private async checkForDeviceVerificationUpgrade( + userId: string, + crossSigningInfo: CrossSigningInfo, + ): Promise { + // only upgrade if this is the first cross-signing key that we've seen for + // them, and if their cross-signing key isn't already verified + const trustLevel = this.crossSigningInfo.checkUserTrust(crossSigningInfo); + if (crossSigningInfo.firstUse && !trustLevel.verified) { + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + const deviceIds = await this.checkForValidDeviceSignature( + userId, crossSigningInfo.keys.master, devices, + ); + if (deviceIds.length) { + return { + devices: deviceIds.map( + deviceId => DeviceInfo.fromStorage(devices[deviceId], deviceId), + ), + crossSigningInfo, + }; + } + } + } + + /** + * Check if the cross-signing key is signed by a verified device. + * + * @param {string} userId the user ID whose key is being checked + * @param {object} key the key that is being checked + * @param {object} devices the user's devices. Should be a map from device ID + * to device info + */ + private async checkForValidDeviceSignature( + userId: string, + key: any, // TODO types + devices: Record, + ): Promise { + const deviceIds: string[] = []; + if (devices && key.signatures && key.signatures[userId]) { + for (const signame of Object.keys(key.signatures[userId])) { + const [, deviceId] = signame.split(':', 2); + if (deviceId in devices + && devices[deviceId].verified === DeviceVerification.VERIFIED) { + try { + await olmlib.verifySignature( + this.olmDevice, + key, + userId, + deviceId, + devices[deviceId].keys[signame], + ); + deviceIds.push(deviceId); + } catch (e) {} + } + } + } + return deviceIds; + } + + /** + * Get the user's cross-signing key ID. + * + * @param {string} [type=master] The type of key to get the ID of. One of + * "master", "self_signing", or "user_signing". Defaults to "master". + * + * @returns {string} the key ID + */ + public getCrossSigningId(type: string): string { + return this.crossSigningInfo.getId(type); + } + + /** + * Get the cross signing information for a given user. + * + * @param {string} userId the user ID to get the cross-signing info for. + * + * @returns {CrossSigningInfo} the cross signing information for the user. + */ + public getStoredCrossSigningForUser(userId: string): CrossSigningInfo { + return this.deviceList.getStoredCrossSigningForUser(userId); + } + + /** + * Check whether a given user is trusted. + * + * @param {string} userId The ID of the user to check. + * + * @returns {UserTrustLevel} + */ + public checkUserTrust(userId: string): UserTrustLevel { + const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (!userCrossSigning) { + return new UserTrustLevel(false, false, false); + } + return this.crossSigningInfo.checkUserTrust(userCrossSigning); + } + + /** + * Check whether a given device is trusted. + * + * @param {string} userId The ID of the user whose devices is to be checked. + * @param {string} deviceId The ID of the device to check + * + * @returns {DeviceTrustLevel} + */ + public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { + const device = this.deviceList.getStoredDevice(userId, deviceId); + return this.checkDeviceInfoTrust(userId, device); + } + + /** + * Check whether a given deviceinfo is trusted. + * + * @param {string} userId The ID of the user whose devices is to be checked. + * @param {module:crypto/deviceinfo?} device The device info object to check + * + * @returns {DeviceTrustLevel} + */ + public checkDeviceInfoTrust(userId: string, device: DeviceInfo): DeviceTrustLevel { + const trustedLocally = !!(device && device.isVerified()); + + const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (device && userCrossSigning) { + // The trustCrossSignedDevices only affects trust of other people's cross-signing + // signatures + const trustCrossSig = this.trustCrossSignedDevices || userId === this.userId; + return this.crossSigningInfo.checkDeviceTrust( + userCrossSigning, device, trustedLocally, trustCrossSig, + ); + } else { + return new DeviceTrustLevel(false, false, trustedLocally, false); + } + } + + /* + * Event handler for DeviceList's userNewDevices event + */ + private onDeviceListUserCrossSigningUpdated = async (userId: string) => { + if (userId === this.userId) { + // An update to our own cross-signing key. + // Get the new key first: + const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + const seenPubkey = newCrossSigning ? newCrossSigning.getId() : null; + const currentPubkey = this.crossSigningInfo.getId(); + const changed = currentPubkey !== seenPubkey; + + if (currentPubkey && seenPubkey && !changed) { + // If it's not changed, just make sure everything is up to date + await this.checkOwnCrossSigningTrust(); + } else { + // We'll now be in a state where cross-signing on the account is not trusted + // because our locally stored cross-signing keys will not match the ones + // on the server for our account. So we clear our own stored cross-signing keys, + // effectively disabling cross-signing until the user gets verified by the device + // that reset the keys + this.storeTrustedSelfKeys(null); + // emit cross-signing has been disabled + this.emit("crossSigning.keysChanged", {}); + // as the trust for our own user has changed, + // also emit an event for this + this.emit("userTrustStatusChanged", + this.userId, this.checkUserTrust(userId)); + } + } else { + await this.checkDeviceVerifications(userId); + + // Update verified before latch using the current state and save the new + // latch value in the device list store. + const crossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (crossSigning) { + crossSigning.updateCrossSigningVerifiedBefore( + this.checkUserTrust(userId).isCrossSigningVerified(), + ); + this.deviceList.setRawStoredCrossSigningForUser( + userId, crossSigning.toStorage(), + ); + } + + this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); + } + }; + + /** + * Check the copy of our cross-signing key that we have in the device list and + * see if we can get the private key. If so, mark it as trusted. + */ + async checkOwnCrossSigningTrust({ + allowPrivateKeyRequests = false, + } = {}) { + const userId = this.userId; + + // Before proceeding, ensure our cross-signing public keys have been + // downloaded via the device list. + await this.downloadKeys([this.userId]); + + // Also check which private keys are locally cached. + const crossSigningPrivateKeys = + await this.crossSigningInfo.getCrossSigningKeysFromCache(); + + // If we see an update to our own master key, check it against the master + // key we have and, if it matches, mark it as verified + + // First, get the new cross-signing info + const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (!newCrossSigning) { + logger.error( + "Got cross-signing update event for user " + userId + + " but no new cross-signing information found!", + ); + return; + } + + const seenPubkey = newCrossSigning.getId(); + const masterChanged = this.crossSigningInfo.getId() !== seenPubkey; + const masterExistsNotLocallyCached = + newCrossSigning.getId() && !crossSigningPrivateKeys.has("master"); + if (masterChanged) { + logger.info("Got new master public key", seenPubkey); + } + if ( + allowPrivateKeyRequests && + (masterChanged || masterExistsNotLocallyCached) + ) { + logger.info("Attempting to retrieve cross-signing master private key"); + let signing = null; + // It's important for control flow that we leave any errors alone for + // higher levels to handle so that e.g. cancelling access properly + // aborts any larger operation as well. + try { + const ret = await this.crossSigningInfo.getCrossSigningKey( + 'master', seenPubkey, + ); + signing = ret[1]; + logger.info("Got cross-signing master private key"); + } finally { + if (signing) signing.free(); + } + } + + const oldSelfSigningId = this.crossSigningInfo.getId("self_signing"); + const oldUserSigningId = this.crossSigningInfo.getId("user_signing"); + + // Update the version of our keys in our cross-signing object and the local store + this.storeTrustedSelfKeys(newCrossSigning.keys); + + const selfSigningChanged = oldSelfSigningId !== newCrossSigning.getId("self_signing"); + const userSigningChanged = oldUserSigningId !== newCrossSigning.getId("user_signing"); + + const selfSigningExistsNotLocallyCached = ( + newCrossSigning.getId("self_signing") && + !crossSigningPrivateKeys.has("self_signing") + ); + const userSigningExistsNotLocallyCached = ( + newCrossSigning.getId("user_signing") && + !crossSigningPrivateKeys.has("user_signing") + ); + + const keySignatures = {}; + + if (selfSigningChanged) { + logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); + } + if ( + allowPrivateKeyRequests && + (selfSigningChanged || selfSigningExistsNotLocallyCached) + ) { + logger.info("Attempting to retrieve cross-signing self-signing private key"); + let signing = null; + try { + const ret = await this.crossSigningInfo.getCrossSigningKey( + "self_signing", newCrossSigning.getId("self_signing"), + ); + signing = ret[1]; + logger.info("Got cross-signing self-signing private key"); + } finally { + if (signing) signing.free(); + } + + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); + const signedDevice = await this.crossSigningInfo.signDevice( + this.userId, device, + ); + keySignatures[this.deviceId] = signedDevice; + } + if (userSigningChanged) { + logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); + } + if ( + allowPrivateKeyRequests && + (userSigningChanged || userSigningExistsNotLocallyCached) + ) { + logger.info("Attempting to retrieve cross-signing user-signing private key"); + let signing = null; + try { + const ret = await this.crossSigningInfo.getCrossSigningKey( + "user_signing", newCrossSigning.getId("user_signing"), + ); + signing = ret[1]; + logger.info("Got cross-signing user-signing private key"); + } finally { + if (signing) signing.free(); + } + } + + if (masterChanged) { + const masterKey = this.crossSigningInfo.keys.master; + await this.signObject(masterKey); + const deviceSig = masterKey.signatures[this.userId]["ed25519:" + this.deviceId]; + // Include only the _new_ device signature in the upload. + // We may have existing signatures from deleted devices, which will cause + // the entire upload to fail. + keySignatures[this.crossSigningInfo.getId()] = Object.assign( + {}, + masterKey, + { + signatures: { + [this.userId]: { + ["ed25519:" + this.deviceId]: deviceSig, + }, + }, + }, + ); + } + + const keysToUpload = Object.keys(keySignatures); + if (keysToUpload.length) { + const upload = ({ shouldEmit }) => { + logger.info(`Starting background key sig upload for ${keysToUpload}`); + return this.baseApis.uploadKeySignatures({ [this.userId]: keySignatures }) + .then((response) => { + const { failures } = response || {}; + logger.info(`Finished background key sig upload for ${keysToUpload}`); + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + "crypto.keySignatureUploadFailure", + failures, + "checkOwnCrossSigningTrust", + upload, + ); + } + throw new KeySignatureUploadError("Key upload failed", { failures }); + } + }).catch(e => { + logger.error( + `Error during background key sig upload for ${keysToUpload}`, + e, + ); + }); + }; + upload({ shouldEmit: true }); + } + + this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); + + if (masterChanged) { + this.baseApis.emit("crossSigning.keysChanged", {}); + await this.afterCrossSigningLocalKeyChange(); + } + + // Now we may be able to trust our key backup + 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)? + } + + /** + * Store a set of keys as our own, trusted, cross-signing keys. + * + * @param {object} keys The new trusted set of keys + */ + private async storeTrustedSelfKeys(keys: any): Promise { // TODO types + if (keys) { + this.crossSigningInfo.setKeys(keys); + } else { + this.crossSigningInfo.clearKeys(); + } + await this.cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningInfo.keys); + }, + ); + } + + /** + * Check if the master key is signed by a verified device, and if so, prompt + * the application to mark it as verified. + * + * @param {string} userId the user ID whose key should be checked + */ + private async checkDeviceVerifications(userId: string): Promise { + const shouldUpgradeCb = ( + this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications + ); + if (!shouldUpgradeCb) { + // Upgrading skipped when callback is not present. + return; + } + logger.info(`Starting device verification upgrade for ${userId}`); + if (this.crossSigningInfo.keys.user_signing) { + const crossSigningInfo = this.deviceList.getStoredCrossSigningForUser(userId); + if (crossSigningInfo) { + const upgradeInfo = await this.checkForDeviceVerificationUpgrade( + userId, crossSigningInfo, + ); + if (upgradeInfo) { + const usersToUpgrade = await shouldUpgradeCb({ + users: { + [userId]: upgradeInfo, + }, + }); + if (usersToUpgrade.includes(userId)) { + await this.baseApis.setDeviceVerified( + userId, crossSigningInfo.getId(), + ); + } + } + } + } + logger.info(`Finished device verification upgrade for ${userId}`); + } + + public async setTrustedBackupPubKey(trustedPubKey: string): Promise { + // This should be redundant post cross-signing is a thing, so just + // plonk it in localStorage for now. + this.sessionStore.setLocalTrustedBackupPubKey(trustedPubKey); + await this.backupManager.checkKeyBackup(); + } + + /** + */ + public enableLazyLoading(): void { + this.lazyLoadMembers = true; + } + + /** + * Tell the crypto module to register for MatrixClient events which it needs to + * listen for + * + * @param {external:EventEmitter} eventEmitter event source where we can register + * for event notifications + */ + public registerEventHandlers(eventEmitter: EventEmitter): void { + eventEmitter.on("RoomMember.membership", (event: MatrixEvent, member: RoomMember, oldMembership?: string) => { + try { + this.onRoomMembership(event, member, oldMembership); + } catch (e) { + logger.error("Error handling membership change:", e); + } + }); + + eventEmitter.on("toDeviceEvent", this.onToDeviceEvent); + eventEmitter.on("Room.timeline", this.onTimelineEvent); + eventEmitter.on("Event.decrypted", this.onTimelineEvent); + } + + /** Start background processes related to crypto */ + public start(): void { + this.outgoingRoomKeyRequestManager.start(); + } + + /** Stop background processes related to crypto */ + public stop(): void { + this.outgoingRoomKeyRequestManager.stop(); + this.deviceList.stop(); + this.dehydrationManager.stop(); + } + + /** + * Get the Ed25519 key for this device + * + * @return {string} base64-encoded ed25519 key. + */ + public getDeviceEd25519Key(): string { + return this.olmDevice.deviceEd25519Key; + } + + /** + * Get the Curve25519 key for this device + * + * @return {string} base64-encoded curve25519 key. + */ + public getDeviceCurve25519Key(): string { + return this.olmDevice.deviceCurve25519Key; + } + + /** + * Set the global override for whether the client should ever send encrypted + * messages to unverified devices. This provides the default for rooms which + * do not specify a value. + * + * @param {boolean} value whether to blacklist all unverified devices by default + */ + public setGlobalBlacklistUnverifiedDevices(value: boolean): void { + this.globalBlacklistUnverifiedDevices = value; + } + + /** + * @return {boolean} whether to blacklist all unverified devices by default + */ + public getGlobalBlacklistUnverifiedDevices(): boolean { + return this.globalBlacklistUnverifiedDevices; + } + + /** + * Set whether sendMessage in a room with unknown and unverified devices + * should throw an error and not send them message. This has 'Global' for + * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently + * no room-level equivalent for this setting. + * + * This API is currently UNSTABLE and may change or be removed without notice. + * + * @param {boolean} value whether error on unknown devices + */ + public setGlobalErrorOnUnknownDevices(value: boolean): void { + this.globalErrorOnUnknownDevices = value; + } + + /** + * @return {boolean} whether to error on unknown devices + * + * This API is currently UNSTABLE and may change or be removed without notice. + */ + public getGlobalErrorOnUnknownDevices(): boolean { + return this.globalErrorOnUnknownDevices; + } + + /** + * Upload the device keys to the homeserver. + * @return {object} A promise that will resolve when the keys are uploaded. + */ + public uploadDeviceKeys(): Promise { + const deviceKeys = { + algorithms: this.supportedAlgorithms, + device_id: this.deviceId, + keys: this.deviceKeys, + user_id: this.userId, + }; + + return this.signObject(deviceKeys).then(() => { + return this.baseApis.uploadKeysRequest({ + device_keys: deviceKeys, + }); + }); + } + + /** + * Stores the current one_time_key count which will be handled later (in a call of + * onSyncCompleted). The count is e.g. coming from a /sync response. + * + * @param {Number} currentCount The current count of one_time_keys to be stored + */ + public updateOneTimeKeyCount(currentCount: number): void { + if (isFinite(currentCount)) { + this.oneTimeKeyCount = currentCount; + } else { + throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number"); + } + } + + public setNeedsNewFallback(needsNewFallback: boolean) { + this.needsNewFallback = !!needsNewFallback; + } + + public getNeedsNewFallback(): boolean { + return this.needsNewFallback; + } + + // check if it's time to upload one-time keys, and do so if so. + private maybeUploadOneTimeKeys() { + // frequency with which to check & upload one-time keys + const uploadPeriod = 1000 * 60; // one minute + + // max number of keys to upload at once + // Creating keys can be an expensive operation so we limit the + // number we generate in one go to avoid blocking the application + // for too long. + const maxKeysPerCycle = 5; + + if (this.oneTimeKeyCheckInProgress) { + return; + } + + const now = Date.now(); + if (this.lastOneTimeKeyCheck !== null && + now - this.lastOneTimeKeyCheck < uploadPeriod + ) { + // we've done a key upload recently. + return; + } + + this.lastOneTimeKeyCheck = now; + + // We need to keep a pool of one time public keys on the server so that + // other devices can start conversations with us. But we can only store + // a finite number of private keys in the olm Account object. + // To complicate things further then can be a delay between a device + // claiming a public one time key from the server and it sending us a + // message. We need to keep the corresponding private key locally until + // we receive the message. + // But that message might never arrive leaving us stuck with duff + // private keys clogging up our local storage. + // So we need some kind of engineering compromise to balance all of + // these factors. + + // Check how many keys we can store in the Account object. + const maxOneTimeKeys = this.olmDevice.maxNumberOfOneTimeKeys(); + // Try to keep at most half that number on the server. This leaves the + // rest of the slots free to hold keys that have been claimed from the + // server but we haven't received a message for. + // If we run out of slots when generating new keys then olm will + // discard the oldest private keys first. This will eventually clean + // out stale private keys that won't receive a message. + const keyLimit = Math.floor(maxOneTimeKeys / 2); + + const uploadLoop = async (keyCount: number) => { + while (keyLimit > keyCount || this.getNeedsNewFallback()) { + // Ask olm to generate new one time keys, then upload them to synapse. + if (keyLimit > keyCount) { + logger.info("generating oneTimeKeys"); + const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle); + await this.olmDevice.generateOneTimeKeys(keysThisLoop); + } + + if (this.getNeedsNewFallback()) { + logger.info("generating fallback key"); + await this.olmDevice.generateFallbackKey(); + } + + logger.info("calling uploadOneTimeKeys"); + const res = await this.uploadOneTimeKeys(); + if (res.one_time_key_counts && res.one_time_key_counts.signed_curve25519) { + // if the response contains a more up to date value use this + // for the next loop + keyCount = res.one_time_key_counts.signed_curve25519; + } else { + throw new Error("response for uploading keys does not contain " + + "one_time_key_counts.signed_curve25519"); + } + } + }; + + this.oneTimeKeyCheckInProgress = true; + Promise.resolve().then(() => { + if (this.oneTimeKeyCount !== undefined) { + // We already have the current one_time_key count from a /sync response. + // Use this value instead of asking the server for the current key count. + return Promise.resolve(this.oneTimeKeyCount); + } + // ask the server how many keys we have + return this.baseApis.uploadKeysRequest({}).then((res) => { + return res.one_time_key_counts.signed_curve25519 || 0; + }); + }).then((keyCount) => { + // Start the uploadLoop with the current keyCount. The function checks if + // we need to upload new keys or not. + // If there are too many keys on the server then we don't need to + // create any more keys. + return uploadLoop(keyCount); + }).catch((e) => { + logger.error("Error uploading one-time keys", e.stack || e); + }).finally(() => { + // reset oneTimeKeyCount to prevent start uploading based on old data. + // it will be set again on the next /sync-response + this.oneTimeKeyCount = undefined; + this.oneTimeKeyCheckInProgress = false; + }); + } + + // returns a promise which resolves to the response + private async uploadOneTimeKeys() { + const promises = []; + + const fallbackJson = {}; + if (this.getNeedsNewFallback()) { + const fallbackKeys = await this.olmDevice.getFallbackKey(); + for (const [keyId, key] of Object.entries(fallbackKeys.curve25519)) { + const k = { key, fallback: true }; + fallbackJson["signed_curve25519:" + keyId] = k; + promises.push(this.signObject(k)); + } + this.setNeedsNewFallback(false); + } + + const oneTimeKeys = await this.olmDevice.getOneTimeKeys(); + const oneTimeJson = {}; + + for (const keyId in oneTimeKeys.curve25519) { + if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { + const k = { + key: oneTimeKeys.curve25519[keyId], + }; + oneTimeJson["signed_curve25519:" + keyId] = k; + promises.push(this.signObject(k)); + } + } + + await Promise.all(promises); + + const res = await this.baseApis.uploadKeysRequest({ + "one_time_keys": oneTimeJson, + "org.matrix.msc2732.fallback_keys": fallbackJson, + }); + + await this.olmDevice.markKeysAsPublished(); + return res; + } + + /** + * Download the keys for a list of users and stores the keys in the session + * store. + * @param {Array} userIds The users to fetch. + * @param {boolean} forceDownload Always download the keys even if cached. + * + * @return {Promise} A promise which resolves to a map userId->deviceId->{@link + * module:crypto/deviceinfo|DeviceInfo}. + */ + public downloadKeys( + userIds: string[], + forceDownload?: boolean, + ): Promise>> { + return this.deviceList.downloadKeys(userIds, forceDownload); + } + + /** + * Get the stored device keys for a user id + * + * @param {string} userId the user to list keys for. + * + * @return {module:crypto/deviceinfo[]|null} list of devices, or null if we haven't + * managed to get a list of devices for this user yet. + */ + public getStoredDevicesForUser(userId: string): Array | null { + return this.deviceList.getStoredDevicesForUser(userId); + } + + /** + * Get the stored keys for a single device + * + * @param {string} userId + * @param {string} deviceId + * + * @return {module:crypto/deviceinfo?} device, or undefined + * if we don't know about this device + */ + public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined { + return this.deviceList.getStoredDevice(userId, deviceId); + } + + /** + * Save the device list, if necessary + * + * @param {number} delay Time in ms before which the save actually happens. + * By default, the save is delayed for a short period in order to batch + * multiple writes, but this behaviour can be disabled by passing 0. + * + * @return {Promise} true if the data was saved, false if + * it was not (eg. because no changes were pending). The promise + * will only resolve once the data is saved, so may take some time + * to resolve. + */ + public saveDeviceList(delay: number): Promise { + return this.deviceList.saveIfDirty(delay); + } + + /** + * Update the blocked/verified state of the given device + * + * @param {string} userId owner of the device + * @param {string} deviceId unique identifier for the device or user's + * cross-signing public key ID. + * + * @param {?boolean} verified whether to mark the device as verified. Null to + * leave unchanged. + * + * @param {?boolean} blocked whether to mark the device as blocked. Null to + * leave unchanged. + * + * @param {?boolean} known whether to mark that the user has been made aware of + * the existence of this device. Null to leave unchanged + * + * @return {Promise} updated DeviceInfo + */ + public async setDeviceVerification( + userId: string, + deviceId: string, + verified?: boolean, + blocked?: boolean, + known?: boolean, + ): Promise { + // get rid of any `undefined`s here so we can just check + // for null rather than null or undefined + if (verified === undefined) verified = null; + if (blocked === undefined) blocked = null; + if (known === undefined) known = null; + + // Check if the 'device' is actually a cross signing key + // The js-sdk's verification treats cross-signing keys as devices + // and so uses this method to mark them verified. + const xsk = this.deviceList.getStoredCrossSigningForUser(userId); + if (xsk && xsk.getId() === deviceId) { + if (blocked !== null || known !== null) { + throw new Error("Cannot set blocked or known for a cross-signing key"); + } + if (!verified) { + throw new Error("Cannot set a cross-signing key as unverified"); + } + + if (!this.crossSigningInfo.getId() && userId === this.crossSigningInfo.userId) { + this.storeTrustedSelfKeys(xsk.keys); + // This will cause our own user trust to change, so emit the event + this.emit( + "userTrustStatusChanged", this.userId, this.checkUserTrust(userId), + ); + } + + // Now sign the master key with our user signing key (unless it's ourself) + if (userId !== this.userId) { + logger.info( + "Master key " + xsk.getId() + " for " + userId + + " marked verified. Signing...", + ); + const device = await this.crossSigningInfo.signUser(xsk); + if (device) { + const upload = async ({ shouldEmit }) => { + logger.info("Uploading signature for " + userId + "..."); + const response = await this.baseApis.uploadKeySignatures({ + [userId]: { + [deviceId]: device, + }, + }); + const { failures } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + "crypto.keySignatureUploadFailure", + failures, + "setDeviceVerification", + upload, + ); + } + /* Throwing here causes the process to be cancelled and the other + * user to be notified */ + throw new KeySignatureUploadError( + "Key upload failed", + { failures }, + ); + } + }; + await upload({ shouldEmit: true }); + + // This will emit events when it comes back down the sync + // (we could do local echo to speed things up) + } + return device; + } else { + return xsk; + } + } + + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + if (!devices || !devices[deviceId]) { + throw new Error("Unknown device " + userId + ":" + deviceId); + } + + const dev = devices[deviceId]; + let verificationStatus = dev.verified; + + if (verified) { + verificationStatus = DeviceVerification.VERIFIED; + } else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) { + verificationStatus = DeviceVerification.UNVERIFIED; + } + + if (blocked) { + verificationStatus = DeviceVerification.BLOCKED; + } else if (blocked !== null && verificationStatus == DeviceVerification.BLOCKED) { + verificationStatus = DeviceVerification.UNVERIFIED; + } + + let knownStatus = dev.known; + if (known !== null) { + knownStatus = known; + } + + if (dev.verified !== verificationStatus || dev.known !== knownStatus) { + dev.verified = verificationStatus; + dev.known = knownStatus; + this.deviceList.storeDevicesForUser(userId, devices); + this.deviceList.saveIfDirty(); + } + + // do cross-signing + if (verified && userId === this.userId) { + logger.info("Own device " + deviceId + " marked verified: signing"); + + // Signing only needed if other device not already signed + let device; + const deviceTrust = this.checkDeviceTrust(userId, deviceId); + if (deviceTrust.isCrossSigningVerified()) { + logger.log(`Own device ${deviceId} already cross-signing verified`); + } else { + device = await this.crossSigningInfo.signDevice( + userId, DeviceInfo.fromStorage(dev, deviceId), + ); + } + + if (device) { + const upload = async ({ shouldEmit }) => { + logger.info("Uploading signature for " + deviceId); + const response = await this.baseApis.uploadKeySignatures({ + [userId]: { + [deviceId]: device, + }, + }); + const { failures } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + "crypto.keySignatureUploadFailure", + failures, + "setDeviceVerification", + upload, // continuation + ); + } + throw new KeySignatureUploadError("Key upload failed", { failures }); + } + }; + await upload({ shouldEmit: true }); + // XXX: we'll need to wait for the device list to be updated + } + } + + const deviceObj = DeviceInfo.fromStorage(dev, deviceId); + this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); + return deviceObj; + } + + public findVerificationRequestDMInProgress(roomId: string): VerificationRequest { + return this.inRoomVerificationRequests.findRequestInProgress(roomId); + } + + public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest { + return this.toDeviceVerificationRequests.getRequestsInProgress(userId); + } + + public requestVerificationDM(userId: string, roomId: string): VerificationRequest { + const existingRequest = this.inRoomVerificationRequests.findRequestInProgress(roomId); + if (existingRequest) { + return Promise.resolve(existingRequest); + } + const channel = new InRoomChannel(this.baseApis, roomId, userId); + return this.requestVerificationWithChannel( + userId, + channel, + this.inRoomVerificationRequests, + ); + } + + public requestVerification(userId: string, devices: string[]): VerificationRequest { + if (!devices) { + devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(userId)); + } + const existingRequest = this.toDeviceVerificationRequests.findRequestInProgress(userId, devices); + if (existingRequest) { + return Promise.resolve(existingRequest); + } + const channel = new ToDeviceChannel(this.baseApis, userId, devices, ToDeviceChannel.makeTransactionId()); + return this.requestVerificationWithChannel( + userId, + channel, + this.toDeviceVerificationRequests, + ); + } + + private async requestVerificationWithChannel( + userId: string, + channel: any, // TODO types + requestsMap: any, // TODO types + ): VerificationRequest { + let request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); + // if transaction id is already known, add request + if (channel.transactionId) { + requestsMap.setRequestByChannel(channel, request); + } + await request.sendRequest(); + // don't replace the request created by a racing remote echo + const racingRequest = requestsMap.getRequestByChannel(channel); + if (racingRequest) { + request = racingRequest; + } else { + logger.log(`Crypto: adding new request to ` + + `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`); + requestsMap.setRequestByChannel(channel, request); + } + return request; + } + + public beginKeyVerification( + method: string, + userId: string, + deviceId: string, + transactionId: string = null, + ): any { // TODO types + let request; + if (transactionId) { + request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId); + if (!request) { + throw new Error( + `No request found for user ${userId} with ` + + `transactionId ${transactionId}`); + } + } else { + transactionId = ToDeviceChannel.makeTransactionId(); + const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); + request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); + this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); + } + return request.beginKeyVerification(method, { userId, deviceId }); + } + + public async legacyDeviceVerification( + userId: string, + deviceId: string, + method: string, + ): VerificationRequest { + const transactionId = ToDeviceChannel.makeTransactionId(); + const channel = new ToDeviceChannel( + this.baseApis, userId, [deviceId], transactionId, deviceId); + const request = new VerificationRequest( + channel, this.verificationMethods, this.baseApis); + this.toDeviceVerificationRequests.setRequestBySenderAndTxnId( + userId, transactionId, request); + const verifier = request.beginKeyVerification(method, { userId, deviceId }); + // either reject by an error from verify() while sending .start + // or resolve when the request receives the + // local (fake remote) echo for sending the .start event + await Promise.race([ + verifier.verify(), + request.waitFor(r => r.started), + ]); + return request; + } + + /** + * Get information on the active olm sessions with a user + *

+ * Returns a map from device id to an object with keys 'deviceIdKey' (the + * device's curve25519 identity key) and 'sessions' (an array of objects in the + * same format as that returned by + * {@link module:crypto/OlmDevice#getSessionInfoForDevice}). + *

+ * This method is provided for debugging purposes. + * + * @param {string} userId id of user to inspect + * + * @return {Promise>} + */ + public async getOlmSessionsForUser(userId: string): Promise> { + const devices = this.getStoredDevicesForUser(userId) || []; + const result = {}; + for (let j = 0; j < devices.length; ++j) { + const device = devices[j]; + const deviceKey = device.getIdentityKey(); + const sessions = await this.olmDevice.getSessionInfoForDevice(deviceKey); + + result[device.deviceId] = { + deviceIdKey: deviceKey, + sessions: sessions, + }; + } + return result; + } + + /** + * Get the device which sent an event + * + * @param {module:models/event.MatrixEvent} event event to be checked + * + * @return {module:crypto/deviceinfo?} + */ + public getEventSenderDeviceInfo(event: MatrixEvent): DeviceInfo | null { + const senderKey = event.getSenderKey(); + const algorithm = event.getWireContent().algorithm; + + if (!senderKey || !algorithm) { + return null; + } + + const forwardingChain = event.getForwardingCurve25519KeyChain(); + if (forwardingChain.length > 0) { + // we got the key this event from somewhere else + // TODO: check if we can trust the forwarders. + return null; + } + + if (event.isKeySourceUntrusted()) { + // we got the key for this event from a source that we consider untrusted + return null; + } + + // senderKey is the Curve25519 identity key of the device which the event + // was sent from. In the case of Megolm, it's actually the Curve25519 + // identity key of the device which set up the Megolm session. + + const device = this.deviceList.getDeviceByIdentityKey( + algorithm, senderKey, + ); + + if (device === null) { + // we haven't downloaded the details of this device yet. + return null; + } + + // so far so good, but now we need to check that the sender of this event + // hadn't advertised someone else's Curve25519 key as their own. We do that + // by checking the Ed25519 claimed by the event (or, in the case of megolm, + // the event which set up the megolm session), to check that it matches the + // fingerprint of the purported sending device. + // + // (see https://github.com/vector-im/vector-web/issues/2215) + + const claimedKey = event.getClaimedEd25519Key(); + if (!claimedKey) { + logger.warn("Event " + event.getId() + " claims no ed25519 key: " + + "cannot verify sending device"); + return null; + } + + if (claimedKey !== device.getFingerprint()) { + logger.warn( + "Event " + event.getId() + " claims ed25519 key " + claimedKey + + " but sender device has key " + device.getFingerprint()); + return null; + } + + return device; + } + + /** + * Get information about the encryption of an event + * + * @param {module:models/event.MatrixEvent} event event to be checked + * + * @return {object} An object with the fields: + * - encrypted: whether the event is encrypted (if not encrypted, some of the + * other properties may not be set) + * - senderKey: the sender's key + * - algorithm: the algorithm used to encrypt the event + * - authenticated: whether we can be sure that the owner of the senderKey + * sent the event + * - sender: the sender's device information, if available + * - mismatchedSender: if the event's ed25519 and curve25519 keys don't match + * (only meaningful if `sender` is set) + */ + public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo { + const ret: Partial = {}; + + ret.senderKey = event.getSenderKey(); + ret.algorithm = event.getWireContent().algorithm; + + if (!ret.senderKey || !ret.algorithm) { + ret.encrypted = false; + return ret as IEncryptedEventInfo; + } + ret.encrypted = true; + + const forwardingChain = event.getForwardingCurve25519KeyChain(); + if (forwardingChain.length > 0 || event.isKeySourceUntrusted()) { + // we got the key this event from somewhere else + // TODO: check if we can trust the forwarders. + ret.authenticated = false; + } else { + ret.authenticated = true; + } + + // senderKey is the Curve25519 identity key of the device which the event + // was sent from. In the case of Megolm, it's actually the Curve25519 + // identity key of the device which set up the Megolm session. + + ret.sender = this.deviceList.getDeviceByIdentityKey(ret.algorithm, ret.senderKey); + + // so far so good, but now we need to check that the sender of this event + // hadn't advertised someone else's Curve25519 key as their own. We do that + // by checking the Ed25519 claimed by the event (or, in the case of megolm, + // the event which set up the megolm session), to check that it matches the + // fingerprint of the purported sending device. + // + // (see https://github.com/vector-im/vector-web/issues/2215) + + const claimedKey = event.getClaimedEd25519Key(); + if (!claimedKey) { + logger.warn("Event " + event.getId() + " claims no ed25519 key: " + + "cannot verify sending device"); + ret.mismatchedSender = true; + } + + if (ret.sender && claimedKey !== ret.sender.getFingerprint()) { + logger.warn( + "Event " + event.getId() + " claims ed25519 key " + claimedKey + + "but sender device has key " + ret.sender.getFingerprint()); + ret.mismatchedSender = true; + } + + return ret as IEncryptedEventInfo; + } + + /** + * Forces the current outbound group session to be discarded such + * that another one will be created next time an event is sent. + * + * @param {string} roomId The ID of the room to discard the session for + * + * This should not normally be necessary. + */ + public forceDiscardSession(roomId: string): void { + const alg = this.roomEncryptors[roomId]; + if (alg === undefined) throw new Error("Room not encrypted"); + if (alg.forceDiscardSession === undefined) { + throw new Error("Room encryption algorithm doesn't support session discarding"); + } + alg.forceDiscardSession(); + } + + /** + * Configure a room to use encryption (ie, save a flag in the cryptoStore). + * + * @param {string} roomId The room ID to enable encryption in. + * + * @param {object} config The encryption config for the room. + * + * @param {boolean=} inhibitDeviceQuery true to suppress device list query for + * users in the room (for now). In case lazy loading is enabled, + * the device query is always inhibited as the members are not tracked. + */ + public async setRoomEncryption( + roomId: string, + config: any, // TODO types + inhibitDeviceQuery?: boolean, + ): Promise { + // ignore crypto events with no algorithm defined + // This will happen if a crypto event is redacted before we fetch the room state + // It would otherwise just throw later as an unknown algorithm would, but we may + // as well catch this here + if (!config.algorithm) { + logger.log("Ignoring setRoomEncryption with no algorithm"); + return; + } + + // if state is being replayed from storage, we might already have a configuration + // for this room as they are persisted as well. + // We just need to make sure the algorithm is initialized in this case. + // However, if the new config is different, + // we should bail out as room encryption can't be changed once set. + const existingConfig = this.roomList.getRoomEncryption(roomId); + if (existingConfig) { + if (JSON.stringify(existingConfig) != JSON.stringify(config)) { + logger.error("Ignoring m.room.encryption event which requests " + + "a change of config in " + roomId); + return; + } + } + // if we already have encryption in this room, we should ignore this event, + // as it would reset the encryption algorithm. + // This is at least expected to be called twice, as sync calls onCryptoEvent + // for both the timeline and state sections in the /sync response, + // the encryption event would appear in both. + // If it's called more than twice though, + // it signals a bug on client or server. + const existingAlg = this.roomEncryptors[roomId]; + if (existingAlg) { + return; + } + + // _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption + // because it first stores in memory. We should await the promise only + // after all the in-memory state (roomEncryptors and _roomList) has been updated + // to avoid races when calling this method multiple times. Hence keep a hold of the promise. + let storeConfigPromise = null; + if (!existingConfig) { + storeConfigPromise = this.roomList.setRoomEncryption(roomId, config); + } + + const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm]; + if (!AlgClass) { + throw new Error("Unable to encrypt with " + config.algorithm); + } + + const alg = new AlgClass({ + userId: this.userId, + deviceId: this.deviceId, + crypto: this, + olmDevice: this.olmDevice, + baseApis: this.baseApis, + roomId: roomId, + config: config, + }); + this.roomEncryptors[roomId] = alg; + + if (storeConfigPromise) { + await storeConfigPromise; + } + + if (!this.lazyLoadMembers) { + logger.log("Enabling encryption in " + roomId + "; " + + "starting to track device lists for all users therein"); + + await this.trackRoomDevices(roomId); + // TODO: this flag is only not used from MatrixClient::setRoomEncryption + // which is never used (inside Element at least) + // but didn't want to remove it as it technically would + // be a breaking change. + if (!inhibitDeviceQuery) { + this.deviceList.refreshOutdatedDeviceLists(); + } + } else { + logger.log("Enabling encryption in " + roomId); + } + } + + /** + * Make sure we are tracking the device lists for all users in this room. + * + * @param {string} roomId The room ID to start tracking devices in. + * @returns {Promise} when all devices for the room have been fetched and marked to track + */ + public trackRoomDevices(roomId: string): Promise { + const trackMembers = async () => { + // not an encrypted room + if (!this.roomEncryptors[roomId]) { + return; + } + const room = this.clientStore.getRoom(roomId); + if (!room) { + throw new Error(`Unable to start tracking devices in unknown room ${roomId}`); + } + logger.log(`Starting to track devices for room ${roomId} ...`); + const members = await room.getEncryptionTargetMembers(); + members.forEach((m) => { + this.deviceList.startTrackingDeviceList(m.userId); + }); + }; + + let promise = this.roomDeviceTrackingState[roomId]; + if (!promise) { + promise = trackMembers(); + this.roomDeviceTrackingState[roomId] = promise.catch(err => { + this.roomDeviceTrackingState[roomId] = null; + throw err; + }); + } + return promise; + } + + /** + * Try to make sure we have established olm sessions for all known devices for + * the given users. + * + * @param {string[]} users list of user ids + * + * @return {Promise} resolves once the sessions are complete, to + * an Object mapping from userId to deviceId to + * {@link module:crypto~OlmSessionResult} + */ + ensureOlmSessionsForUsers(users: string[]): Promise { + const devicesByUser = {}; + + for (let i = 0; i < users.length; ++i) { + const userId = users[i]; + devicesByUser[userId] = []; + + const devices = this.getStoredDevicesForUser(userId) || []; + for (let j = 0; j < devices.length; ++j) { + const deviceInfo = devices[j]; + + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother setting up session to ourself + continue; + } + if (deviceInfo.verified == DeviceVerification.BLOCKED) { + // don't bother setting up sessions with blocked users + continue; + } + + devicesByUser[userId].push(deviceInfo); + } + } + + return olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, devicesByUser, + ); + } + + /** + * Get a list containing all of the room keys + * + * @return {module:crypto/OlmDevice.MegolmSessionData[]} a list of session export objects + */ + public async exportRoomKeys(): Promise { + const exportedSessions = []; + await this.cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + this.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => { + if (s === null) return; + + const sess = this.olmDevice.exportInboundGroupSession( + s.senderKey, s.sessionId, s.sessionData, + ); + delete sess.first_known_index; + sess.algorithm = olmlib.MEGOLM_ALGORITHM; + exportedSessions.push(sess); + }); + }, + ); + + return exportedSessions; + } + + /** + * Import a list of room keys previously exported by exportRoomKeys + * + * @param {Object[]} keys a list of session export objects + * @param {Object} opts + * @param {Function} opts.progressCallback called with an object which has a stage param + * @return {Promise} a promise which resolves once the keys have been imported + */ + public importRoomKeys(keys: IRoomKey[], opts: any = {}): Promise { // TODO types + let successes = 0; + let failures = 0; + const total = keys.length; + + function updateProgress() { + opts.progressCallback({ + stage: "load_keys", + successes, + failures, + total, + }); + } + + return Promise.all(keys.map((key) => { + if (!key.room_id || !key.algorithm) { + logger.warn("ignoring room key entry with missing fields", key); + failures++; + if (opts.progressCallback) { updateProgress(); } + return null; + } + + const alg = this.getRoomDecryptor(key.room_id, key.algorithm); + return alg.importRoomKey(key, opts).finally((r) => { + successes++; + if (opts.progressCallback) { updateProgress(); } + }); + })); + } + + /** + * Counts the number of end to end session keys that are waiting to be backed up + * @returns {Promise} Resolves to the number of sessions requiring backup + */ + public countSessionsNeedingBackup(): Promise { + return this.backupManager.countSessionsNeedingBackup(); + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param {module:models/room} room the room the event is in + */ + public prepareToEncrypt(room: Room): void { + const alg = this.roomEncryptors[room.roomId]; + if (alg) { + alg.prepareToEncrypt(room); + } + } + + /* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307 + /** + * Encrypt an event according to the configuration of the room. + * + * @param {module:models/event.MatrixEvent} event event to be sent + * + * @param {module:models/room} room destination room. + * + * @return {Promise?} Promise which resolves when the event has been + * encrypted, or null if nothing was needed + */ + /* eslint-enable valid-jsdoc */ + // TODO this return type lies + public async encryptEvent(event: MatrixEvent, room: Room): Promise { + if (!room) { + throw new Error("Cannot send encrypted messages in unknown rooms"); + } + + const roomId = event.getRoomId(); + + const alg = this.roomEncryptors[roomId]; + if (!alg) { + // MatrixClient has already checked that this room should be encrypted, + // so this is an unexpected situation. + throw new Error( + "Room was previously configured to use encryption, but is " + + "no longer. Perhaps the homeserver is hiding the " + + "configuration event.", + ); + } + + if (!this.roomDeviceTrackingState[roomId]) { + this.trackRoomDevices(roomId); + } + // wait for all the room devices to be loaded + await this.roomDeviceTrackingState[roomId]; + + let content = event.getContent(); + // If event has an m.relates_to then we need + // to put this on the wrapping event instead + const mRelatesTo = content['m.relates_to']; + if (mRelatesTo) { + // Clone content here so we don't remove `m.relates_to` from the local-echo + content = Object.assign({}, content); + delete content['m.relates_to']; + } + + const encryptedContent = await alg.encryptMessage( + room, event.getType(), content); + + if (mRelatesTo) { + encryptedContent['m.relates_to'] = mRelatesTo; + } + + event.makeEncrypted( + "m.room.encrypted", + encryptedContent, + this.olmDevice.deviceCurve25519Key, + this.olmDevice.deviceEd25519Key, + ); + } + + /** + * Decrypt a received event + * + * @param {MatrixEvent} event + * + * @return {Promise} resolves once we have + * finished decrypting. Rejects with an `algorithms.DecryptionError` if there + * is a problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise { + if (event.isRedacted()) { + const redactionEvent = new MatrixEvent(event.getUnsigned().redacted_because); + const decryptedEvent = await this.decryptEvent(redactionEvent); + + return { + clearEvent: { + room_id: event.getRoomId(), + type: "m.room.message", + content: {}, + unsigned: { + redacted_because: decryptedEvent.clearEvent, + }, + }, + }; + } else { + const content = event.getWireContent(); + const alg = this.getRoomDecryptor(event.getRoomId(), content.algorithm); + return await alg.decryptEvent(event); + } + } + + /** + * Handle the notification from /sync or /keys/changes that device lists have + * been changed. + * + * @param {Object} syncData Object containing sync tokens associated with this sync + * @param {Object} syncDeviceLists device_lists field from /sync, or response from + * /keys/changes + */ + public async handleDeviceListChanges(syncData: ISyncData, syncDeviceLists: ISyncDeviceLists): Promise { + // Initial syncs don't have device change lists. We'll either get the complete list + // of changes for the interval or will have invalidated everything in willProcessSync + if (!syncData.oldSyncToken) return; + + // Here, we're relying on the fact that we only ever save the sync data after + // sucessfully saving the device list data, so we're guaranteed that the device + // list store is at least as fresh as the sync token from the sync store, ie. + // any device changes received in sync tokens prior to the 'next' token here + // have been processed and are reflected in the current device list. + // If we didn't make this assumption, we'd have to use the /keys/changes API + // to get key changes between the sync token in the device list and the 'old' + // sync token used here to make sure we didn't miss any. + await this.evalDeviceListChanges(syncDeviceLists); + } + + /** + * Send a request for some room keys, if we have not already done so + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * @param {Array<{userId: string, deviceId: string}>} recipients + * @param {boolean} resend whether to resend the key request if there is + * already one + * + * @return {Promise} a promise that resolves when the key request is queued + */ + public requestRoomKey( + requestBody: IRoomKeyRequestBody, + recipients: IRoomKeyRequestRecipient[], + resend = false, + ): Promise { + return this.outgoingRoomKeyRequestManager.queueRoomKeyRequest( + requestBody, recipients, resend, + ).then(() => { + if (this.sendKeyRequestsImmediately) { + this.outgoingRoomKeyRequestManager.sendQueuedRequests(); + } + }).catch((e) => { + // this normally means we couldn't talk to the store + logger.error( + 'Error requesting key for event', e, + ); + }); + } + + /** + * Cancel any earlier room key request + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * parameters to match for cancellation + */ + public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): void { + this.outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody) + .catch((e) => { + logger.warn("Error clearing pending room key requests", e); + }); + } + + /** + * Re-send any outgoing key requests, eg after verification + * @returns {Promise} + */ + public cancelAndResendAllOutgoingKeyRequests(): Promise { + return this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); + } + + /** + * handle an m.room.encryption event + * + * @param {module:models/event.MatrixEvent} event encryption event + */ + public async onCryptoEvent(event: MatrixEvent): Promise { + const roomId = event.getRoomId(); + const content = event.getContent(); + + try { + // inhibit the device list refresh for now - it will happen once we've + // finished processing the sync, in onSyncCompleted. + await this.setRoomEncryption(roomId, content, true); + } catch (e) { + logger.error("Error configuring encryption in room " + roomId + + ":", e); + } + } + + /** + * Called before the result of a sync is processed + * + * @param {Object} syncData the data from the 'MatrixClient.sync' event + */ + public async onSyncWillProcess(syncData: ISyncData): Promise { + if (!syncData.oldSyncToken) { + // If there is no old sync token, we start all our tracking from + // scratch, so mark everything as untracked. onCryptoEvent will + // be called for all e2e rooms during the processing of the sync, + // at which point we'll start tracking all the users of that room. + logger.log("Initial sync performed - resetting device tracking state"); + this.deviceList.stopTrackingAllDeviceLists(); + // we always track our own device list (for key backups etc) + this.deviceList.startTrackingDeviceList(this.userId); + this.roomDeviceTrackingState = {}; + } + + this.sendKeyRequestsImmediately = false; + } + + /** + * handle the completion of a /sync + * + * This is called after the processing of each successful /sync response. + * It is an opportunity to do a batch process on the information received. + * + * @param {Object} syncData the data from the 'MatrixClient.sync' event + */ + public async onSyncCompleted(syncData: ISyncData): Promise { + const nextSyncToken = syncData.nextSyncToken; + + this.deviceList.setSyncToken(syncData.nextSyncToken); + this.deviceList.saveIfDirty(); + + // catch up on any new devices we got told about during the sync. + this.deviceList.lastKnownSyncToken = nextSyncToken; + + // we always track our own device list (for key backups etc) + this.deviceList.startTrackingDeviceList(this.userId); + + this.deviceList.refreshOutdatedDeviceLists(); + + // we don't start uploading one-time keys until we've caught up with + // to-device messages, to help us avoid throwing away one-time-keys that we + // are about to receive messages for + // (https://github.com/vector-im/element-web/issues/2782). + if (!syncData.catchingUp) { + this.maybeUploadOneTimeKeys(); + this.processReceivedRoomKeyRequests(); + + // likewise don't start requesting keys until we've caught up + // on to_device messages, otherwise we'll request keys that we're + // just about to get. + this.outgoingRoomKeyRequestManager.sendQueuedRequests(); + + // Sync has finished so send key requests straight away. + this.sendKeyRequestsImmediately = true; + } + } + + /** + * Trigger the appropriate invalidations and removes for a given + * device list + * + * @param {Object} deviceLists device_lists field from /sync, or response from + * /keys/changes + */ + private async evalDeviceListChanges(deviceLists: ISyncDeviceLists): Promise { + if (deviceLists.changed && Array.isArray(deviceLists.changed)) { + deviceLists.changed.forEach((u) => { + this.deviceList.invalidateUserDeviceList(u); + }); + } + + if (deviceLists.left && Array.isArray(deviceLists.left) && + deviceLists.left.length) { + // Check we really don't share any rooms with these users + // any more: the server isn't required to give us the + // exact correct set. + const e2eUserIds = new Set(await this.getTrackedE2eUsers()); + + deviceLists.left.forEach((u) => { + if (!e2eUserIds.has(u)) { + this.deviceList.stopTrackingDeviceList(u); + } + }); + } + } + + /** + * Get a list of all the IDs of users we share an e2e room with + * for which we are tracking devices already + * + * @returns {string[]} List of user IDs + */ + private async getTrackedE2eUsers(): Promise { + const e2eUserIds = []; + for (const room of this.getTrackedE2eRooms()) { + const members = await room.getEncryptionTargetMembers(); + for (const member of members) { + e2eUserIds.push(member.userId); + } + } + return e2eUserIds; + } + + /** + * Get a list of the e2e-enabled rooms we are members of, + * and for which we are already tracking the devices + * + * @returns {module:models.Room[]} + */ + private getTrackedE2eRooms(): Room[] { + return this.clientStore.getRooms().filter((room) => { + // check for rooms with encryption enabled + const alg = this.roomEncryptors[room.roomId]; + if (!alg) { + return false; + } + if (!this.roomDeviceTrackingState[room.roomId]) { + return false; + } + + // ignore any rooms which we have left + const myMembership = room.getMyMembership(); + return myMembership === "join" || myMembership === "invite"; + }); + } + + private onToDeviceEvent = (event: MatrixEvent): void => { + try { + logger.log(`received to_device ${event.getType()} from: ` + + `${event.getSender()} id: ${event.getId()}`); + + if (event.getType() == "m.room_key" + || event.getType() == "m.forwarded_room_key") { + this.onRoomKeyEvent(event); + } else if (event.getType() == "m.room_key_request") { + this.onRoomKeyRequestEvent(event); + } else if (event.getType() === "m.secret.request") { + this.secretStorage._onRequestReceived(event); + } else if (event.getType() === "m.secret.send") { + this.secretStorage._onSecretReceived(event); + } else if (event.getType() === "org.matrix.room_key.withheld") { + this.onRoomKeyWithheldEvent(event); + } else if (event.getContent().transaction_id) { + this.onKeyVerificationMessage(event); + } else if (event.getContent().msgtype === "m.bad.encrypted") { + this.onToDeviceBadEncrypted(event); + } else if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { + if (!event.isBeingDecrypted()) { + event.attemptDecryption(this); + } + // once the event has been decrypted, try again + event.once('Event.decrypted', (ev) => { + this.onToDeviceEvent(ev); + }); + } + } catch (e) { + logger.error("Error handling toDeviceEvent:", e); + } + }; + + /** + * Handle a key event + * + * @private + * @param {module:models/event.MatrixEvent} event key event + */ + private onRoomKeyEvent(event: MatrixEvent): void { + const content = event.getContent(); + + if (!content.room_id || !content.algorithm) { + logger.error("key event is missing fields"); + return; + } + + 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.backupManager.checkAndStart(); + } + + const alg = this.getRoomDecryptor(content.room_id, content.algorithm); + alg.onRoomKeyEvent(event); + } + + /** + * Handle a key withheld event + * + * @private + * @param {module:models/event.MatrixEvent} event key withheld event + */ + private onRoomKeyWithheldEvent(event: MatrixEvent): void { + const content = event.getContent(); + + if ((content.code !== "m.no_olm" && (!content.room_id || !content.session_id)) + || !content.algorithm || !content.sender_key) { + logger.error("key withheld event is missing fields"); + return; + } + + logger.info( + `Got room key withheld event from ${event.getSender()} (${content.sender_key}) ` + + `for ${content.algorithm}/${content.room_id}/${content.session_id} ` + + `with reason ${content.code} (${content.reason})`, + ); + + const alg = this.getRoomDecryptor(content.room_id, content.algorithm); + if (alg.onRoomKeyWithheldEvent) { + alg.onRoomKeyWithheldEvent(event); + } + if (!content.room_id) { + // retry decryption for all events sent by the sender_key. This will + // update the events to show a message indicating that the olm session was + // wedged. + const roomDecryptors = this.getRoomDecryptors(content.algorithm); + for (const decryptor of roomDecryptors) { + decryptor.retryDecryptionFromSender(content.sender_key); + } + } + } + + /** + * Handle a general key verification event. + * + * @private + * @param {module:models/event.MatrixEvent} event verification start event + */ + private onKeyVerificationMessage(event: MatrixEvent): void { + if (!ToDeviceChannel.validateEvent(event, this.baseApis)) { + return; + } + const createRequest = event => { + if (!ToDeviceChannel.canCreateRequest(ToDeviceChannel.getEventType(event))) { + return; + } + const content = event.getContent(); + const deviceId = content && content.from_device; + if (!deviceId) { + return; + } + const userId = event.getSender(); + const channel = new ToDeviceChannel( + this.baseApis, + userId, + [deviceId], + ); + return new VerificationRequest( + channel, this.verificationMethods, this.baseApis); + }; + this.handleVerificationEvent( + event, + this.toDeviceVerificationRequests, + createRequest, + ); + } + + /** + * Handle key verification requests sent as timeline events + * + * @private + * @param {module:models/event.MatrixEvent} event the timeline event + * @param {module:models/Room} room not used + * @param {boolean} atStart not used + * @param {boolean} removed not used + * @param {boolean} { liveEvent } whether this is a live event + */ + private onTimelineEvent = ( + event: MatrixEvent, + room: Room, + atStart: boolean, + removed: boolean, + { liveEvent = true } = {}, + ): void => { + if (!InRoomChannel.validateEvent(event, this.baseApis)) { + return; + } + const createRequest = event => { + const channel = new InRoomChannel( + this.baseApis, + event.getRoomId(), + ); + return new VerificationRequest( + channel, this.verificationMethods, this.baseApis); + }; + this.handleVerificationEvent( + event, + this.inRoomVerificationRequests, + createRequest, + liveEvent, + ); + }; + + private async handleVerificationEvent( + event: MatrixEvent, + requestsMap: any, // TODO types + createRequest: any, // TODO types + isLiveEvent = true, + ): Promise { + let request = requestsMap.getRequest(event); + let isNewRequest = false; + if (!request) { + request = createRequest(event); + // a request could not be made from this event, so ignore event + if (!request) { + logger.log(`Crypto: could not find VerificationRequest for ` + + `${event.getType()}, and could not create one, so ignoring.`); + return; + } + isNewRequest = true; + requestsMap.setRequest(event, request); + } + event.setVerificationRequest(request); + try { + await request.channel.handleEvent(event, request, isLiveEvent); + } catch (err) { + logger.error("error while handling verification event: " + err.message); + } + const shouldEmit = isNewRequest && + !request.initiatedByMe && + !request.invalid && // check it has enough events to pass the UNSENT stage + !request.observeOnly; + if (shouldEmit) { + this.baseApis.emit("crypto.verification.request", request); + } + } + + /** + * Handle a toDevice event that couldn't be decrypted + * + * @private + * @param {module:models/event.MatrixEvent} event undecryptable event + */ + private async onToDeviceBadEncrypted(event: MatrixEvent): Promise { + const content = event.getWireContent(); + const sender = event.getSender(); + const algorithm = content.algorithm; + const deviceKey = content.sender_key; + + // retry decryption for all events sent by the sender_key. This will + // update the events to show a message indicating that the olm session was + // wedged. + const retryDecryption = () => { + const roomDecryptors = this.getRoomDecryptors(olmlib.MEGOLM_ALGORITHM); + for (const decryptor of roomDecryptors) { + decryptor.retryDecryptionFromSender(deviceKey); + } + }; + + if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { + return; + } + + // check when we last forced a new session with this device: if we've already done so + // recently, don't do it again. + this.lastNewSessionForced[sender] = this.lastNewSessionForced[sender] || {}; + const lastNewSessionForced = this.lastNewSessionForced[sender][deviceKey] || 0; + if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) { + logger.debug( + "New session already forced with device " + sender + ":" + deviceKey + + " at " + lastNewSessionForced + ": not forcing another", + ); + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); + retryDecryption(); + return; + } + + // establish a new olm session with this device since we're failing to decrypt messages + // on a current session. + // Note that an undecryptable message from another device could easily be spoofed - + // is there anything we can do to mitigate this? + let device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.downloadKeys([sender], false); + device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); + if (!device) { + logger.info( + "Couldn't find device for identity key " + deviceKey + + ": not re-establishing session", + ); + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", false); + retryDecryption(); + return; + } + } + const devicesByUser = {}; + devicesByUser[sender] = [device]; + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, devicesByUser, true, + ); + + this.lastNewSessionForced[sender][deviceKey] = Date.now(); + + // Now send a blank message on that session so the other side knows about it. + // (The keyshare request is sent in the clear so that won't do) + // We send this first such that, as long as the toDevice messages arrive in the + // same order we sent them, the other end will get this first, set up the new session, + // then get the keyshare request and send the key over this new session (because it + // is the session it has most recently received a message on). + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + sender, + device, + { type: "m.dummy" }, + ); + + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); + retryDecryption(); + + await this.baseApis.sendToDevice("m.room.encrypted", { + [sender]: { + [device.deviceId]: encryptedContent, + }, + }); + + // Most of the time this probably won't be necessary since we'll have queued up a key request when + // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending + // it. This won't always be the case though so we need to re-send any that have already been sent + // to avoid races. + const requestsToResend = + await this.outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest( + sender, device.deviceId, + ); + for (const keyReq of requestsToResend) { + this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true); + } + } + + /** + * Handle a change in the membership state of a member of a room + * + * @private + * @param {module:models/event.MatrixEvent} event event causing the change + * @param {module:models/room-member} member user whose membership changed + * @param {string=} oldMembership previous membership + */ + private onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void { + // this event handler is registered on the *client* (as opposed to the room + // member itself), which means it is only called on changes to the *live* + // membership state (ie, it is not called when we back-paginate, nor when + // we load the state in the initialsync). + // + // Further, it is automatically registered and called when new members + // arrive in the room. + + const roomId = member.roomId; + + const alg = this.roomEncryptors[roomId]; + if (!alg) { + // not encrypting in this room + return; + } + // only mark users in this room as tracked if we already started tracking in this room + // this way we don't start device queries after sync on behalf of this room which we won't use + // the result of anyway, as we'll need to do a query again once all the members are fetched + // by calling _trackRoomDevices + if (this.roomDeviceTrackingState[roomId]) { + if (member.membership == 'join') { + logger.log('Join event for ' + member.userId + ' in ' + roomId); + // make sure we are tracking the deviceList for this user + this.deviceList.startTrackingDeviceList(member.userId); + } else if (member.membership == 'invite' && + this.clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) { + logger.log('Invite event for ' + member.userId + ' in ' + roomId); + this.deviceList.startTrackingDeviceList(member.userId); + } + } + + alg.onRoomMembership(event, member, oldMembership); + } + + /** + * Called when we get an m.room_key_request event. + * + * @private + * @param {module:models/event.MatrixEvent} event key request event + */ + private onRoomKeyRequestEvent(event: MatrixEvent): void { + const content = event.getContent(); + if (content.action === "request") { + // Queue it up for now, because they tend to arrive before the room state + // events at initial sync, and we want to see if we know anything about the + // room before passing them on to the app. + const req = new IncomingRoomKeyRequest(event); + this.receivedRoomKeyRequests.push(req); + } else if (content.action === "request_cancellation") { + const req = new IncomingRoomKeyRequestCancellation(event); + this.receivedRoomKeyRequestCancellations.push(req); + } + } + + /** + * Process any m.room_key_request events which were queued up during the + * current sync. + * + * @private + */ + private async processReceivedRoomKeyRequests(): Promise { + if (this.processingRoomKeyRequests) { + // we're still processing last time's requests; keep queuing new ones + // up for now. + return; + } + this.processingRoomKeyRequests = true; + + try { + // we need to grab and clear the queues in the synchronous bit of this method, + // so that we don't end up racing with the next /sync. + const requests = this.receivedRoomKeyRequests; + this.receivedRoomKeyRequests = []; + const cancellations = this.receivedRoomKeyRequestCancellations; + this.receivedRoomKeyRequestCancellations = []; + + // Process all of the requests, *then* all of the cancellations. + // + // This makes sure that if we get a request and its cancellation in the + // same /sync result, then we process the request before the + // cancellation (and end up with a cancelled request), rather than the + // cancellation before the request (and end up with an outstanding + // request which should have been cancelled.) + await Promise.all(requests.map((req) => + this.processReceivedRoomKeyRequest(req))); + await Promise.all(cancellations.map((cancellation) => + this.processReceivedRoomKeyRequestCancellation(cancellation))); + } catch (e) { + logger.error(`Error processing room key requsts: ${e}`); + } finally { + this.processingRoomKeyRequests = false; + } + } + + /** + * Helper for processReceivedRoomKeyRequests + * + * @param {IncomingRoomKeyRequest} req + */ + private async processReceivedRoomKeyRequest(req: IncomingRoomKeyRequest): Promise { + const userId = req.userId; + const deviceId = req.deviceId; + + const body = req.requestBody; + const roomId = body.room_id; + const alg = body.algorithm; + + logger.log(`m.room_key_request from ${userId}:${deviceId}` + + ` for ${roomId} / ${body.session_id} (id ${req.requestId})`); + + if (userId !== this.userId) { + if (!this.roomEncryptors[roomId]) { + logger.debug(`room key request for unencrypted room ${roomId}`); + return; + } + const encryptor = this.roomEncryptors[roomId]; + const device = this.deviceList.getStoredDevice(userId, deviceId); + if (!device) { + logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); + return; + } + + try { + await encryptor.reshareKeyWithDevice( + body.sender_key, body.session_id, userId, device, + ); + } catch (e) { + logger.warn( + "Failed to re-share keys for session " + body.session_id + + " with device " + userId + ":" + device.deviceId, e, + ); + } + return; + } + + if (deviceId === this.deviceId) { + // We'll always get these because we send room key requests to + // '*' (ie. 'all devices') which includes the sending device, + // so ignore requests from ourself because apart from it being + // very silly, it won't work because an Olm session cannot send + // messages to itself. + // The log here is probably superfluous since we know this will + // always happen, but let's log anyway for now just in case it + // causes issues. + logger.log("Ignoring room key request from ourselves"); + return; + } + + // todo: should we queue up requests we don't yet have keys for, + // in case they turn up later? + + // if we don't have a decryptor for this room/alg, we don't have + // the keys for the requested events, and can drop the requests. + if (!this.roomDecryptors[roomId]) { + logger.log(`room key request for unencrypted room ${roomId}`); + return; + } + + const decryptor = this.roomDecryptors[roomId][alg]; + if (!decryptor) { + logger.log(`room key request for unknown alg ${alg} in room ${roomId}`); + return; + } + + if (!await decryptor.hasKeysForKeyRequest(req)) { + logger.log( + `room key request for unknown session ${roomId} / ` + + body.session_id, + ); + return; + } + + req.share = () => { + decryptor.shareKeysWithDevice(req); + }; + + // if the device is verified already, share the keys + if (this.checkDeviceTrust(userId, deviceId).isVerified()) { + logger.log('device is already verified: sharing keys'); + req.share(); + return; + } + + this.emit("crypto.roomKeyRequest", req); + } + + /** + * Helper for processReceivedRoomKeyRequests + * + * @param {IncomingRoomKeyRequestCancellation} cancellation + */ + private async processReceivedRoomKeyRequestCancellation( + cancellation: IncomingRoomKeyRequestCancellation, + ): Promise { + logger.log( + `m.room_key_request cancellation for ${cancellation.userId}:` + + `${cancellation.deviceId} (id ${cancellation.requestId})`, + ); + + // we should probably only notify the app of cancellations we told it + // about, but we don't currently have a record of that, so we just pass + // everything through. + this.emit("crypto.roomKeyRequestCancellation", cancellation); + } + + /** + * Get a decryptor for a given room and algorithm. + * + * If we already have a decryptor for the given room and algorithm, return + * it. Otherwise try to instantiate it. + * + * @private + * + * @param {string?} roomId room id for decryptor. If undefined, a temporary + * decryptor is instantiated. + * + * @param {string} algorithm crypto algorithm + * + * @return {module:crypto.algorithms.base.DecryptionAlgorithm} + * + * @raises {module:crypto.algorithms.DecryptionError} if the algorithm is + * unknown + */ + public getRoomDecryptor(roomId: string, algorithm: string): DecryptionAlgorithm { + let decryptors: Record; + let alg: DecryptionAlgorithm; + + roomId = roomId || null; + if (roomId) { + decryptors = this.roomDecryptors[roomId]; + if (!decryptors) { + this.roomDecryptors[roomId] = decryptors = {}; + } + + alg = decryptors[algorithm]; + if (alg) { + return alg; + } + } + + const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm]; + if (!AlgClass) { + throw new algorithms.DecryptionError( + 'UNKNOWN_ENCRYPTION_ALGORITHM', + 'Unknown encryption algorithm "' + algorithm + '".', + ); + } + alg = new AlgClass({ + userId: this.userId, + crypto: this, + olmDevice: this.olmDevice, + baseApis: this.baseApis, + roomId: roomId, + }); + + if (decryptors) { + decryptors[algorithm] = alg; + } + return alg; + } + + /** + * Get all the room decryptors for a given encryption algorithm. + * + * @param {string} algorithm The encryption algorithm + * + * @return {array} An array of room decryptors + */ + private getRoomDecryptors(algorithm: string): DecryptionAlgorithm[] { + const decryptors = []; + for (const d of Object.values(this.roomDecryptors)) { + if (algorithm in d) { + decryptors.push(d[algorithm]); + } + } + return decryptors; + } + + /** + * sign the given object with our ed25519 key + * + * @param {Object} obj Object to which we will add a 'signatures' property + */ + public async signObject(obj: object & ISignableObject): Promise { + const sigs = obj.signatures || {}; + const unsigned = obj.unsigned; + + delete obj.signatures; + delete obj.unsigned; + + sigs[this.userId] = sigs[this.userId] || {}; + sigs[this.userId]["ed25519:" + this.deviceId] = await this.olmDevice.sign(anotherjson.stringify(obj)); + obj.signatures = sigs; + if (unsigned !== undefined) obj.unsigned = unsigned; + } +} + +/** + * Fix up the backup key, that may be in the wrong format due to a bug in a + * migration step. Some backup keys were stored as a comma-separated list of + * integers, rather than a base64-encoded byte array. If this function is + * passed a string that looks like a list of integers rather than a base64 + * string, it will attempt to convert it to the right format. + * + * @param {string} key the key to check + * @returns {null | string} If the key is in the wrong format, then the fixed + * key will be returned. Otherwise null will be returned. + * + */ +export function fixBackupKey(key: string): string | null { + if (typeof key !== "string" || key.indexOf(",") < 0) { + return null; + } + const fixedKey = Uint8Array.from(key.split(","), x => parseInt(x)); + return olmlib.encodeBase64(fixedKey); +} + +/** + * The parameters of a room key request. The details of the request may + * vary with the crypto algorithm, but the management and storage layers for + * outgoing requests expect it to have 'room_id' and 'session_id' properties. + * + * @typedef {Object} RoomKeyRequestBody + */ + +/** + * Represents a received m.room_key_request event + * + * @property {string} userId user requesting the key + * @property {string} deviceId device requesting the key + * @property {string} requestId unique id for the request + * @property {module:crypto~RoomKeyRequestBody} requestBody + * @property {function()} share callback which, when called, will ask + * the relevant crypto algorithm implementation to share the keys for + * this request. + */ +class IncomingRoomKeyRequest { + public readonly userId: string; + public readonly deviceId: string; + public readonly requestId: string; + public readonly requestBody: IRoomKeyRequestBody; + public share: () => void; + + constructor(event: MatrixEvent) { + const content = event.getContent(); + + this.userId = event.getSender(); + this.deviceId = content.requesting_device_id; + this.requestId = content.request_id; + this.requestBody = content.body || {}; + this.share = () => { + throw new Error("don't know how to share keys for this request yet"); + }; + } +} + +/** + * Represents a received m.room_key_request cancellation + * + * @property {string} userId user requesting the cancellation + * @property {string} deviceId device requesting the cancellation + * @property {string} requestId unique id for the request to be cancelled + */ +class IncomingRoomKeyRequestCancellation { + public readonly userId: string; + public readonly deviceId: string; + public readonly requestId: string; + + constructor(event: MatrixEvent) { + const content = event.getContent(); + + this.userId = event.getSender(); + this.deviceId = content.requesting_device_id; + this.requestId = content.request_id; + } +} + +/** + * The result of a (successful) call to decryptEvent. + * + * @typedef {Object} EventDecryptionResult + * + * @property {Object} clearEvent The plaintext payload for the event + * (typically containing type and content fields). + * + * @property {?string} senderCurve25519Key Key owned by the sender of this + * event. See {@link module:models/event.MatrixEvent#getSenderKey}. + * + * @property {?string} claimedEd25519Key ed25519 key claimed by the sender of + * this event. See + * {@link module:models/event.MatrixEvent#getClaimedEd25519Key}. + * + * @property {?Array} forwardingCurve25519KeyChain list of curve25519 + * keys involved in telling us about the senderCurve25519Key and + * claimedEd25519Key. See + * {@link module:models/event.MatrixEvent#getForwardingCurve25519KeyChain}. + */ + +/** + * Fires when we receive a room key request + * + * @event module:client~MatrixClient#"crypto.roomKeyRequest" + * @param {module:crypto~IncomingRoomKeyRequest} req request details + */ + +/** + * Fires when we receive a room key request cancellation + * + * @event module:client~MatrixClient#"crypto.roomKeyRequestCancellation" + * @param {module:crypto~IncomingRoomKeyRequestCancellation} req + */ + +/** + * Fires when the app may wish to warn the user about something related + * the end-to-end crypto. + * + * @event module:client~MatrixClient#"crypto.warning" + * @param {string} type One of the strings listed above + */ diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index c5a7979f2..e67db035c 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -41,18 +41,7 @@ export interface IKeyBackupVersion { 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[]; - }[]; + recovery_key: string; // eslint-disable-line camelcase } export interface IKeyBackupPrepareOpts { From 27a6d1f87882adfe9c4fdea7369ffcb68e216cb6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sat, 19 Jun 2021 19:44:47 +0100 Subject: [PATCH 082/124] Fix missing await identified by TS conversion --- src/crypto/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/crypto/index.ts b/src/crypto/index.ts index e3ab46e6c..25108382a 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -567,7 +567,7 @@ export class Crypto extends EventEmitter { ); const sessionBackupInStorage = ( !this.backupManager.getKeyBackupEnabled() || - this.baseApis.isKeyBackupKeyStored() + await this.baseApis.isKeyBackupKeyStored() ); return !!( @@ -2661,7 +2661,7 @@ export class Crypto extends EventEmitter { } const alg = this.getRoomDecryptor(key.room_id, key.algorithm); - return alg.importRoomKey(key, opts).finally((r) => { + return alg.importRoomKey(key, opts).finally(() => { successes++; if (opts.progressCallback) { updateProgress(); } }); From 66b17aa0194a8e55952562117dc7215b5bf1adf7 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sat, 19 Jun 2021 19:45:29 +0100 Subject: [PATCH 083/124] Add type for another-json dep --- src/@types/another-json.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/@types/another-json.ts diff --git a/src/@types/another-json.ts b/src/@types/another-json.ts new file mode 100644 index 000000000..070332a5c --- /dev/null +++ b/src/@types/another-json.ts @@ -0,0 +1,19 @@ +/* +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. +*/ + +declare module "another-json" { + export function stringify(o: object): string; +} From 1f728cab92ca0daf3fa99d0b5fb33b5155eb26b9 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 21 Jun 2021 16:20:25 +0100 Subject: [PATCH 084/124] Prepare changelog for v12.0.0 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a0fb78d..609cf3c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +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) From 467b75f2dc12ae25dfde77d48f2a572a57404d3a Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 21 Jun 2021 16:20:26 +0100 Subject: [PATCH 085/124] v12.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 26d7d3f76..912fd99c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "12.0.0-rc.1", + "version": "12.0.0", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", From 284e2bb911d2dffeff4c819436d1fde20d56646b Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 21 Jun 2021 16:28:40 +0100 Subject: [PATCH 086/124] Resetting package fields for development --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 6b57ec732..a5d28feef 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "keywords": [ "matrix-org" ], - "main": "./lib/index.js", + "main": "./src/index.ts", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -109,6 +109,5 @@ "coverageReporters": [ "text" ] - }, - "typings": "./lib/index.d.ts" + } } From cf76375ce09998953395361de603f351a3cc4987 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 21 Jun 2021 21:04:29 +0100 Subject: [PATCH 087/124] Move Promise::allSettled typing from react-sdk to js-sdk --- src/@types/global.d.ts | 13 +++++++++++++ tsconfig.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 337768428..2f4164e03 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -84,4 +84,17 @@ declare global { // on webkit: we should check if we still need to do this webkitGetUserMedia: DummyInterfaceWeShouldntBeUsingThis; } + + export interface ISettledFulfilled { + status: "fulfilled"; + value: T; + } + export interface ISettledRejected { + status: "rejected"; + reason: any; + } + + interface PromiseConstructor { + allSettled(promises: Promise[]): Promise | ISettledRejected>>; + } } diff --git a/tsconfig.json b/tsconfig.json index 3bcac0171..548bbe7fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "esModuleInterop": true, "module": "commonjs", "moduleResolution": "node", - "target": "es2020", + "target": "es2016", "noImplicitAny": false, "sourceMap": true, "outDir": "./lib", From a93de99d429361470058cb7335277e9e8b54a3b1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 22 Jun 2021 17:18:54 +0100 Subject: [PATCH 088/124] Improve type of IContent msgtype --- src/models/event.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index ba16939cc..fad11511e 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -24,7 +24,7 @@ import { EventEmitter } from 'events'; import { logger } from '../logger'; import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; -import { EventType, RelationType } from "../@types/event"; +import { EventType, MsgType, RelationType } from "../@types/event"; import { Crypto } from "../crypto"; import { deepSortedObjectEntries } from "../utils"; @@ -62,9 +62,9 @@ function intern(str: string): string { } /* eslint-disable camelcase */ -interface IContent { +export interface IContent { [key: string]: any; - msgtype?: string; + msgtype?: MsgType | string; membership?: string; avatar_url?: string; displayname?: string; From 7bda13aba60d64b6867fcf7ea185e679fc45e5a5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 22 Jun 2021 20:37:50 +0100 Subject: [PATCH 089/124] Fix types of MatrixEvent sender & target --- src/models/event.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index 6e71902ba..edcee4ad5 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -27,6 +27,7 @@ import { VerificationRequest } from "../crypto/verification/request/Verification import { EventType, MsgType, RelationType } from "../@types/event"; import { Crypto } from "../crypto"; import { deepSortedObjectEntries } from "../utils"; +import { RoomMember } from "./room-member"; /** * Enum for event statuses. @@ -198,8 +199,8 @@ export class MatrixEvent extends EventEmitter { private readonly localTimestamp: number; // XXX: these should be read-only - public sender = null; - public target = null; + public sender: RoomMember = null; + public target: RoomMember = null; public status: EventStatus = null; public error = null; public forwardLooking = true; From b18f0ff738985a538491312abb3aa6fe82aa548d Mon Sep 17 00:00:00 2001 From: Aaron Raimist Date: Tue, 22 Jun 2021 22:34:28 -0500 Subject: [PATCH 090/124] Do not honor string power levels Signed-off-by: Aaron Raimist --- spec/unit/room-member.spec.js | 28 ++++++++++++++++++++++++++++ src/models/room-member.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/spec/unit/room-member.spec.js b/spec/unit/room-member.spec.js index ef59d404a..7449c6a04 100644 --- a/spec/unit/room-member.spec.js +++ b/spec/unit/room-member.spec.js @@ -124,6 +124,34 @@ describe("RoomMember", function() { expect(member.powerLevel).toEqual(0); expect(emitCount).toEqual(1); }); + + it("should not honor string power levels.", + function() { + const event = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@alice:bar": "5", + }, + }, + event: true, + }); + let emitCount = 0; + + member.on("RoomMember.powerLevel", function(emitEvent, emitMember) { + emitCount += 1; + expect(emitMember.userId).toEqual('@alice:bar'); + expect(emitMember.powerLevel).toEqual(20); + expect(emitEvent).toEqual(event); + }); + + member.setPowerLevelEvent(event); + expect(member.powerLevel).toEqual(20); + expect(emitCount).toEqual(1); + }); }); describe("setTypingEvent", function() { diff --git a/src/models/room-member.ts b/src/models/room-member.ts index de9437c07..589fddd95 100644 --- a/src/models/room-member.ts +++ b/src/models/room-member.ts @@ -163,7 +163,7 @@ export class RoomMember extends EventEmitter { const oldPowerLevel = this.powerLevel; const oldPowerLevelNorm = this.powerLevelNorm; - if (users[this.userId] !== undefined) { + if (users[this.userId] !== undefined && Number.isInteger(users[this.userId])) { this.powerLevel = users[this.userId]; } else if (evContent.users_default !== undefined) { this.powerLevel = evContent.users_default; From 69050ed33887e8afabe917776fa924551de0b8b4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 23 Jun 2021 13:12:26 +0100 Subject: [PATCH 091/124] Fix import cycle --- src/crypto/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 25108382a..1a5401e65 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -49,7 +49,10 @@ import { decryptAES, encryptAES } from './aes'; import { DehydrationManager } from './dehydration'; import { BackupManager } from "./backup"; import { IStore } from "../store"; -import { Room, RoomMember, MatrixEvent, MatrixClient, IKeysUploadResponse } from ".."; +import { Room } from "../models/room"; +import { RoomMember } from "../models/room-member"; +import { MatrixEvent } from "../models/event"; +import { MatrixClient, IKeysUploadResponse } from "../client"; import type { EncryptionAlgorithm, DecryptionAlgorithm } from "./algorithms/base"; import type { RoomList } from "./RoomList"; import { IRecoveryKey, IEncryptedEventInfo } from "./api"; @@ -65,7 +68,7 @@ const defaultVerificationMethods = { // to start. [SHOW_QR_CODE_METHOD]: IllegalMethod, [SCAN_QR_CODE_METHOD]: IllegalMethod, -} +}; /** * verification method names @@ -73,7 +76,7 @@ const defaultVerificationMethods = { export const verificationMethods = { RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME, SAS: SAS.NAME, -} +}; export function isCryptoAvailable(): boolean { return Boolean(global.Olm); From 6017fead19cf5519cc7e3e85ba1ff5143a04390a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 23 Jun 2021 13:33:56 +0100 Subject: [PATCH 092/124] Fix imports --- src/client.ts | 2 +- src/crypto/index.ts | 6 +++--- src/crypto/keybackup.ts | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/client.ts b/src/client.ts index 58a3ab3ff..d6b4aecda 100644 --- a/src/client.ts +++ b/src/client.ts @@ -48,7 +48,7 @@ import { retryNetworkOperation, } from "./http-api"; import { Crypto, fixBackupKey, IBootstrapCrossSigningOpts, isCryptoAvailable } from './crypto'; -import { DeviceInfo } from "./crypto/DeviceInfo"; +import { DeviceInfo } from "./crypto/deviceinfo"; import { decodeRecoveryKey } from './crypto/recoverykey'; import { keyFromAuthData } from './crypto/key_passphrase'; import { User } from "./models/user"; diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 1a5401e65..205a21952 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -52,7 +52,7 @@ import { IStore } from "../store"; import { Room } from "../models/room"; import { RoomMember } from "../models/room-member"; import { MatrixEvent } from "../models/event"; -import { MatrixClient, IKeysUploadResponse } from "../client"; +import { MatrixClient, IKeysUploadResponse, SessionStore, CryptoStore } from "../client"; import type { EncryptionAlgorithm, DecryptionAlgorithm } from "./algorithms/base"; import type { RoomList } from "./RoomList"; import { IRecoveryKey, IEncryptedEventInfo } from "./api"; @@ -282,11 +282,11 @@ export class Crypto extends EventEmitter { */ constructor( private readonly baseApis: MatrixClient, - public readonly sessionStore: any, // TODO types + public readonly sessionStore: SessionStore, private readonly userId: string, private readonly deviceId: string, private readonly clientStore: IStore, - public readonly cryptoStore: any, // TODO types + public readonly cryptoStore: CryptoStore, private readonly roomList: RoomList, verificationMethods: any[], // TODO types ) { diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index e67db035c..c2fe3a1ce 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -15,7 +15,6 @@ limitations under the License. */ import { ISignatures } from "../@types/signed"; -import { DeviceInfo } from "./deviceinfo"; export interface IKeyBackupSession { first_message_index: number; // eslint-disable-line camelcase From 5a8299f1a5655e382df1b3407ebaa803d4c1f7a6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 23 Jun 2021 14:47:25 +0100 Subject: [PATCH 093/124] Convert more of js-sdk crypto and fix underscored field accesses --- spec/integ/devicelist-integ-spec.js | 16 +- spec/integ/matrix-client-crypto.spec.js | 2 +- spec/integ/matrix-client-methods.spec.js | 2 +- spec/integ/megolm-integ.spec.js | 2 +- spec/unit/crypto.spec.js | 8 +- spec/unit/crypto/algorithms/megolm.spec.js | 30 +- spec/unit/crypto/backup.spec.js | 4 +- spec/unit/crypto/cross-signing.spec.js | 54 +- spec/unit/crypto/crypto-utils.js | 10 +- spec/unit/crypto/secrets.spec.js | 34 +- spec/unit/crypto/verification/request.spec.js | 2 +- spec/unit/crypto/verification/sas.spec.js | 12 +- src/client.ts | 20 +- .../{CrossSigning.js => CrossSigning.ts} | 269 ++++----- src/crypto/{DeviceList.js => DeviceList.ts} | 555 +++++++++--------- src/crypto/EncryptionSetup.js | 2 +- src/crypto/{RoomList.js => RoomList.ts} | 41 +- src/crypto/SecretStorage.js | 8 +- src/crypto/dehydration.ts | 34 +- src/crypto/deviceinfo.js | 168 ------ src/crypto/deviceinfo.ts | 175 ++++++ src/crypto/index.ts | 21 +- .../{key_passphrase.js => key_passphrase.ts} | 28 +- src/crypto/keybackup.ts | 11 +- src/crypto/verification/Base.js | 2 +- src/matrix.ts | 13 +- src/utils.ts | 10 +- 27 files changed, 789 insertions(+), 744 deletions(-) rename src/crypto/{CrossSigning.js => CrossSigning.ts} (75%) rename src/crypto/{DeviceList.js => DeviceList.ts} (62%) rename src/crypto/{RoomList.js => RoomList.ts} (52%) delete mode 100644 src/crypto/deviceinfo.js create mode 100644 src/crypto/deviceinfo.ts rename src/crypto/{key_passphrase.js => key_passphrase.ts} (76%) diff --git a/spec/integ/devicelist-integ-spec.js b/spec/integ/devicelist-integ-spec.js index cdad8a905..2ca459119 100644 --- a/spec/integ/devicelist-integ-spec.js +++ b/spec/integ/devicelist-integ-spec.js @@ -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']; diff --git a/spec/integ/matrix-client-crypto.spec.js b/spec/integ/matrix-client-crypto.spec.js index 6bb1a494b..eb87c5193 100644 --- a/spec/integ/matrix-client-crypto.spec.js +++ b/spec/integ/matrix-client-crypto.spec.js @@ -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]; diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.js index b133b456e..acf353970 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.js @@ -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); diff --git a/spec/integ/megolm-integ.spec.js b/spec/integ/megolm-integ.spec.js index d129590e1..e2bc34c25 100644 --- a/spec/integ/megolm-integ.spec.js +++ b/spec/integ/megolm-integ.spec.js @@ -1013,7 +1013,7 @@ 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, diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index bda03089a..816b952b1 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -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, @@ -285,7 +285,7 @@ describe("Crypto", function() { } })); - const bobDecryptor = bobClient.crypto._getRoomDecryptor( + const bobDecryptor = bobClient.crypto.getRoomDecryptor( roomId, olmlib.MEGOLM_ALGORITHM, ); @@ -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); diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index 8b56b93b3..b3afc3e6c 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -365,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", @@ -404,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); }; @@ -468,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", @@ -508,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); }; @@ -561,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: { @@ -605,13 +605,13 @@ describe("MegolmDecryption", function() { bobClient.initCrypto(), ]); aliceClient.crypto.downloadKeys = async () => {}; - const bobDevice = bobClient.crypto._olmDevice; + 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: { @@ -655,7 +655,7 @@ describe("MegolmDecryption", function() { aliceClient.initCrypto(), bobClient.initCrypto(), ]); - const bobDevice = bobClient.crypto._olmDevice; + const bobDevice = bobClient.crypto.olmDevice; aliceClient.crypto.downloadKeys = async () => {}; const roomId = "!someroom"; @@ -663,7 +663,7 @@ describe("MegolmDecryption", function() { 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: { diff --git a/spec/unit/crypto/backup.spec.js b/spec/unit/crypto/backup.spec.js index 1bb8a39f8..df65475d4 100644 --- a/spec/unit/crypto/backup.spec.js +++ b/spec/unit/crypto/backup.spec.js @@ -296,7 +296,7 @@ describe("MegolmBackup", function() { resolve(); return Promise.resolve({}); }; - client.crypto._backupManager.backupGroupSession( + client.crypto.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", groupSession.session_id(), ); @@ -478,7 +478,7 @@ describe("MegolmBackup", function() { ); } }; - client.crypto._backupManager.backupGroupSession( + client.crypto.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", groupSession.session_id(), ); diff --git a/spec/unit/crypto/cross-signing.spec.js b/spec/unit/crypto/cross-signing.spec.js index 56b86b26b..3118e6365 100644 --- a/spec/unit/crypto/cross-signing.spec.js +++ b/spec/unit/crypto/cross-signing.spec.js @@ -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", @@ -203,12 +203,12 @@ describe("Cross Signing", function() { alice.uploadKeySignatures = jest.fn(async (content) => { try { await olmlib.verifySignature( - alice.crypto._olmDevice, + alice.crypto.olmDevice, content["@alice:example.com"][ "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk" ], "@alice:example.com", - "Osborne2", alice.crypto._olmDevice.deviceEd25519Key, + "Osborne2", alice.crypto.olmDevice.deviceEd25519Key, ); olmlib.pkVerify( content["@alice:example.com"]["Osborne2"], @@ -222,7 +222,7 @@ describe("Cross Signing", function() { }); }); - 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", @@ -230,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 @@ -358,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", @@ -387,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 @@ -421,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 () => {}; @@ -437,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", @@ -452,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(); @@ -606,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", @@ -629,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 @@ -673,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", @@ -701,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 @@ -733,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", @@ -770,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, }); @@ -805,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 () => {}; @@ -838,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"); @@ -848,7 +848,7 @@ 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); }); diff --git a/spec/unit/crypto/crypto-utils.js b/spec/unit/crypto/crypto-utils.js index dcc9db16a..dbf6cec65 100644 --- a/spec/unit/crypto/crypto-utils.js +++ b/spec/unit/crypto/crypto-utils.js @@ -8,22 +8,22 @@ export async function resetCrossSigningKeys(client, { } = {}) { const crypto = client.crypto; - const oldKeys = Object.assign({}, crypto._crossSigningInfo.keys); + const oldKeys = Object.assign({}, crypto.crossSigningInfo.keys); try { - await crypto._crossSigningInfo.resetKeys(level); - await crypto._signObject(crypto._crossSigningInfo.keys.master); + await crypto.crossSigningInfo.resetKeys(level); + await crypto._signObject(crypto.crossSigningInfo.keys.master); // write a copy locally so we know these are trusted keys await crypto._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { crypto._cryptoStore.storeCrossSigningKeys( - txn, crypto._crossSigningInfo.keys); + txn, crypto.crossSigningInfo.keys); }, ); } catch (e) { // If anything failed here, revert the keys so we know to try again from the start // next time. - crypto._crossSigningInfo.keys = oldKeys; + crypto.crossSigningInfo.keys = oldKeys; throw e; } crypto._baseApis.emit("crossSigning.keysChanged", {}); diff --git a/spec/unit/crypto/secrets.spec.js b/spec/unit/crypto/secrets.spec.js index fc82a3259..2a86dfaa1 100644 --- a/spec/unit/crypto/secrets.spec.js +++ b/spec/unit/crypto/secrets.spec.js @@ -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._backupManager.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", diff --git a/spec/unit/crypto/verification/request.spec.js b/spec/unit/crypto/verification/request.spec.js index 11275e6fc..3ad4fe562 100644 --- a/spec/unit/crypto/verification/request.spec.js +++ b/spec/unit/crypto/verification/request.spec.js @@ -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: { diff --git a/spec/unit/crypto/verification/sas.spec.js b/spec/unit/crypto/verification/sas.spec.js index 0a643d318..fcb73de29 100644 --- a/spec/unit/crypto/verification/sas.spec.js +++ b/spec/unit/crypto/verification/sas.spec.js @@ -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, }, ); diff --git a/src/client.ts b/src/client.ts index d6b4aecda..91beeef39 100644 --- a/src/client.ts +++ b/src/client.ts @@ -48,7 +48,7 @@ import { retryNetworkOperation, } from "./http-api"; import { Crypto, fixBackupKey, IBootstrapCrossSigningOpts, isCryptoAvailable } from './crypto'; -import { DeviceInfo } from "./crypto/deviceinfo"; +import { DeviceInfo, IDevice } from "./crypto/deviceinfo"; import { decodeRecoveryKey } from './crypto/recoverykey'; import { keyFromAuthData } from './crypto/key_passphrase'; import { User } from "./models/user"; @@ -64,7 +64,7 @@ import { import { IIdentityServerProvider } from "./@types/IIdentityServerProvider"; import type Request from "request"; import { MatrixScheduler } from "./scheduler"; -import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo, NotificationCountType } from "./matrix"; +import { ICryptoCallbacks, ISecretStorageKeyInfo, NotificationCountType } from "./matrix"; import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store"; import { LocalStorageCryptoStore } from "./crypto/store/localStorage-crypto-store"; import { IndexedDBCryptoStore } from "./crypto/store/indexeddb-crypto-store"; @@ -85,7 +85,7 @@ import { IRecoveryKey, ISecretStorageKey, } from "./crypto/api"; -import { CrossSigningInfo, UserTrustLevel } from "./crypto/CrossSigning"; +import { CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./crypto/CrossSigning"; import { Room } from "./models/room"; import { ICreateRoomOpts, @@ -1265,7 +1265,7 @@ export class MatrixClient extends EventEmitter { public downloadKeys( userIds: string[], forceDownload?: boolean, - ): Promise>> { + ): Promise>> { if (!this.crypto) { return Promise.reject(new Error("End-to-end encryption disabled")); } @@ -1571,9 +1571,9 @@ export class MatrixClient extends EventEmitter { * @param {string} userId The ID of the user whose devices is to be checked. * @param {string} deviceId The ID of the device to check * - * @returns {IDeviceTrustLevel} + * @returns {DeviceTrustLevel} */ - public checkDeviceTrust(userId: string, deviceId: string): IDeviceTrustLevel { + public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1948,7 +1948,7 @@ export class MatrixClient extends EventEmitter { * * @return {Promise} */ - public getEventSenderDeviceInfo(event: MatrixEvent): Promise { + public async getEventSenderDeviceInfo(event: MatrixEvent): Promise { if (!this.crypto) { return null; } @@ -2488,15 +2488,13 @@ export class MatrixClient extends EventEmitter { targetRoomId: string, targetSessionId: string, backupInfo: IKeyBackupVersion, - opts: IKeyBackupRestoreOpts, + opts?: IKeyBackupRestoreOpts, ): Promise { const privKey = await this.crypto.getSessionBackupPrivateKey(); if (!privKey) { throw new Error("Couldn't get key"); } - return this.restoreKeyBackup( - privKey, targetRoomId, targetSessionId, backupInfo, opts, - ); + return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts); } private async restoreKeyBackup( diff --git a/src/crypto/CrossSigning.js b/src/crypto/CrossSigning.ts similarity index 75% rename from src/crypto/CrossSigning.js rename to src/crypto/CrossSigning.ts index e2af6ce3b..2d983c2e5 100644 --- a/src/crypto/CrossSigning.js +++ b/src/crypto/CrossSigning.ts @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,22 +19,43 @@ limitations under the License. * @module crypto/CrossSigning */ -import { decodeBase64, encodeBase64, pkSign, pkVerify } from './olmlib'; import { EventEmitter } from 'events'; + +import { decodeBase64, encodeBase64, pkSign, pkVerify } from './olmlib'; import { logger } from '../logger'; import { IndexedDBCryptoStore } from '../crypto/store/indexeddb-crypto-store'; import { decryptAES, encryptAES } from './aes'; +import { PkSigning } from "@matrix-org/olm"; +import { DeviceInfo } from "./deviceinfo"; +import { SecretStorage } from "./SecretStorage"; +import { CryptoStore, MatrixClient } from "../client"; +import { OlmDevice } from "./OlmDevice"; +import { ICryptoCallbacks } from "../matrix"; const KEY_REQUEST_TIMEOUT_MS = 1000 * 60; -function publicKeyFromKeyInfo(keyInfo) { +function publicKeyFromKeyInfo(keyInfo: any): any { // TODO types // `keys` is an object with { [`ed25519:${pubKey}`]: pubKey } // We assume only a single key, and we want the bare form without type // prefix, so we select the values. return Object.values(keyInfo.keys)[0]; } +interface ICacheCallbacks { + getCrossSigningKeyCache?(type: string, expectedPublicKey?: string): Promise; + storeCrossSigningKeyCache?(type: string, key: Uint8Array): Promise; +} + export class CrossSigningInfo extends EventEmitter { + public keys: Record = {}; // TODO types + public firstUse = true; + // This tracks whether we've ever verified this user with any identity. + // When you verify a user, any devices online at the time that receive + // the verifying signature via the homeserver will latch this to true + // and can use it in the future to detect cases where the user has + // become unverified later for any reason. + private crossSigningVerifiedBefore = false; + /** * Information about a user's cross-signing keys * @@ -46,27 +66,15 @@ export class CrossSigningInfo extends EventEmitter { * Requires getCrossSigningKey and saveCrossSigningKeys * @param {object} cacheCallbacks Callbacks used to interact with the cache */ - constructor(userId, callbacks, cacheCallbacks) { + constructor( + public readonly userId: string, + private callbacks: ICryptoCallbacks = {}, + private cacheCallbacks: ICacheCallbacks = {}, + ) { super(); - - // you can't change the userId - Object.defineProperty(this, 'userId', { - enumerable: true, - value: userId, - }); - this._callbacks = callbacks || {}; - this._cacheCallbacks = cacheCallbacks || {}; - this.keys = {}; - this.firstUse = true; - // This tracks whether we've ever verified this user with any identity. - // When you verify a user, any devices online at the time that receive - // the verifying signature via the homeserver will latch this to true - // and can use it in the future to detect cases where the user has - // become unverifed later for any reason. - this.crossSigningVerifiedBefore = false; } - static fromStorage(obj, userId) { + public static fromStorage(obj: object, userId: string): CrossSigningInfo { const res = new CrossSigningInfo(userId); for (const prop in obj) { if (obj.hasOwnProperty(prop)) { @@ -76,7 +84,7 @@ export class CrossSigningInfo extends EventEmitter { return res; } - toStorage() { + public toStorage(): object { return { keys: this.keys, firstUse: this.firstUse, @@ -92,10 +100,10 @@ export class CrossSigningInfo extends EventEmitter { * the stored public key for the given key type. * @returns {Array} An array with [ public key, Olm.PkSigning ] */ - async getCrossSigningKey(type, expectedPubkey) { + public async getCrossSigningKey(type: string, expectedPubkey?: string): Promise<[string, PkSigning]> { const shouldCache = ["master", "self_signing", "user_signing"].indexOf(type) >= 0; - if (!this._callbacks.getCrossSigningKey) { + if (!this.callbacks.getCrossSigningKey) { throw new Error("No getCrossSigningKey callback supplied"); } @@ -103,7 +111,7 @@ export class CrossSigningInfo extends EventEmitter { expectedPubkey = this.getId(type); } - function validateKey(key) { + function validateKey(key: Uint8Array): [string, PkSigning] { if (!key) return; const signing = new global.Olm.PkSigning(); const gotPubkey = signing.init_with_seed(key); @@ -114,9 +122,8 @@ export class CrossSigningInfo extends EventEmitter { } let privkey; - if (this._cacheCallbacks.getCrossSigningKeyCache && shouldCache) { - privkey = await this._cacheCallbacks - .getCrossSigningKeyCache(type, expectedPubkey); + if (this.cacheCallbacks.getCrossSigningKeyCache && shouldCache) { + privkey = await this.cacheCallbacks.getCrossSigningKeyCache(type, expectedPubkey); } const cacheresult = validateKey(privkey); @@ -124,11 +131,11 @@ export class CrossSigningInfo extends EventEmitter { return cacheresult; } - privkey = await this._callbacks.getCrossSigningKey(type, expectedPubkey); + privkey = await this.callbacks.getCrossSigningKey(type, expectedPubkey); const result = validateKey(privkey); if (result) { - if (this._cacheCallbacks.storeCrossSigningKeyCache && shouldCache) { - await this._cacheCallbacks.storeCrossSigningKeyCache(type, privkey); + if (this.cacheCallbacks.storeCrossSigningKeyCache && shouldCache) { + await this.cacheCallbacks.storeCrossSigningKeyCache(type, privkey); } return result; } @@ -156,10 +163,9 @@ export class CrossSigningInfo extends EventEmitter { * with, or null if it is not present or not encrypted with a trusted * key */ - async isStoredInSecretStorage(secretStorage) { + public async isStoredInSecretStorage(secretStorage: SecretStorage): Promise> { // check what SSSS keys have encrypted the master key (if any) - const stored = - await secretStorage.isStored("m.cross_signing.master", false) || {}; + const stored = await secretStorage.isStored("m.cross_signing.master", false) || {}; // then check which of those SSSS keys have also encrypted the SSK and USK function intersect(s) { for (const k of Object.keys(stored)) { @@ -169,9 +175,7 @@ export class CrossSigningInfo extends EventEmitter { } } for (const type of ["self_signing", "user_signing"]) { - intersect( - await secretStorage.isStored(`m.cross_signing.${type}`, false) || {}, - ); + intersect(await secretStorage.isStored(`m.cross_signing.${type}`, false) || {}); } return Object.keys(stored).length ? stored : null; } @@ -184,7 +188,10 @@ export class CrossSigningInfo extends EventEmitter { * @param {Map} keys The keys to store * @param {SecretStorage} secretStorage The secret store using account data */ - static async storeInSecretStorage(keys, secretStorage) { + public static async storeInSecretStorage( + keys: Map, + secretStorage: SecretStorage, + ): Promise { for (const [type, privateKey] of keys) { const encodedKey = encodeBase64(privateKey); await secretStorage.store(`m.cross_signing.${type}`, encodedKey); @@ -200,7 +207,7 @@ export class CrossSigningInfo extends EventEmitter { * @param {SecretStorage} secretStorage The secret store using account data * @return {Uint8Array} The private key */ - static async getFromSecretStorage(type, secretStorage) { + public static async getFromSecretStorage(type: string, secretStorage: SecretStorage): Promise { const encodedKey = await secretStorage.get(`m.cross_signing.${type}`); if (!encodedKey) { return null; @@ -215,8 +222,8 @@ export class CrossSigningInfo extends EventEmitter { * "self_signing", or "user_signing". Optional, will check all by default. * @returns {boolean} True if all keys are stored in the local cache. */ - async isStoredInKeyCache(type) { - const cacheCallbacks = this._cacheCallbacks; + public async isStoredInKeyCache(type?: string): Promise { + const cacheCallbacks = this.cacheCallbacks; if (!cacheCallbacks) return false; const types = type ? [type] : ["master", "self_signing", "user_signing"]; for (const t of types) { @@ -232,9 +239,9 @@ export class CrossSigningInfo extends EventEmitter { * * @returns {Map} A map from key type (string) to private key (Uint8Array) */ - async getCrossSigningKeysFromCache() { + public async getCrossSigningKeysFromCache(): Promise> { const keys = new Map(); - const cacheCallbacks = this._cacheCallbacks; + const cacheCallbacks = this.cacheCallbacks; if (!cacheCallbacks) return keys; for (const type of ["master", "self_signing", "user_signing"]) { const privKey = await cacheCallbacks.getCrossSigningKeyCache(type); @@ -255,8 +262,7 @@ export class CrossSigningInfo extends EventEmitter { * * @return {string} the ID */ - getId(type) { - type = type || "master"; + public getId(type = "master"): string { if (!this.keys[type]) return null; const keyInfo = this.keys[type]; return publicKeyFromKeyInfo(keyInfo); @@ -269,8 +275,8 @@ export class CrossSigningInfo extends EventEmitter { * * @param {CrossSigningLevel} level The key types to reset */ - async resetKeys(level) { - if (!this._callbacks.saveCrossSigningKeys) { + public async resetKeys(level?: CrossSigningLevel): Promise { + if (!this.callbacks.saveCrossSigningKeys) { throw new Error("No saveCrossSigningKeys callback supplied"); } @@ -289,8 +295,8 @@ export class CrossSigningInfo extends EventEmitter { return; } - const privateKeys = {}; - const keys = {}; + const privateKeys: Record = {}; + const keys: Record = {}; let masterSigning; let masterPub; @@ -347,7 +353,7 @@ export class CrossSigningInfo extends EventEmitter { } Object.assign(this.keys, keys); - this._callbacks.saveCrossSigningKeys(privateKeys); + this.callbacks.saveCrossSigningKeys(privateKeys); } finally { if (masterSigning) { masterSigning.free(); @@ -358,12 +364,12 @@ export class CrossSigningInfo extends EventEmitter { /** * unsets the keys, used when another session has reset the keys, to disable cross-signing */ - clearKeys() { + public clearKeys(): void { this.keys = {}; } - setKeys(keys) { - const signingKeys = {}; + public setKeys(keys: Record): void { + const signingKeys: Record = {}; if (keys.master) { if (keys.master.user_id !== this.userId) { const error = "Mismatched user ID " + keys.master.user_id + @@ -434,7 +440,7 @@ export class CrossSigningInfo extends EventEmitter { } } - updateCrossSigningVerifiedBefore(isCrossSigningVerified) { + public updateCrossSigningVerifiedBefore(isCrossSigningVerified: boolean): void { // It is critical that this value latches forward from false to true but // never back to false to avoid a downgrade attack. if (!this.crossSigningVerifiedBefore && isCrossSigningVerified) { @@ -442,7 +448,7 @@ export class CrossSigningInfo extends EventEmitter { } } - async signObject(data, type) { + public async signObject(data: T, type: string): Promise { if (!this.keys[type]) { throw new Error( "Attempted to sign with " + type + " key but no such key present", @@ -457,7 +463,7 @@ export class CrossSigningInfo extends EventEmitter { } } - async signUser(key) { + public async signUser(key: CrossSigningInfo): Promise { if (!this.keys.user_signing) { logger.info("No user signing key: not signing user"); return; @@ -465,7 +471,7 @@ export class CrossSigningInfo extends EventEmitter { return this.signObject(key.keys.master, "user_signing"); } - async signDevice(userId, device) { + public async signDevice(userId: string, device: DeviceInfo): Promise { if (userId !== this.userId) { throw new Error( `Trying to sign ${userId}'s device; can only sign our own device`, @@ -492,7 +498,7 @@ export class CrossSigningInfo extends EventEmitter { * * @returns {UserTrustLevel} */ - checkUserTrust(userCrossSigning) { + public checkUserTrust(userCrossSigning: CrossSigningInfo): UserTrustLevel { // if we're checking our own key, then it's trusted if the master key // and self-signing key match if (this.userId === userCrossSigning.userId @@ -530,12 +536,17 @@ export class CrossSigningInfo extends EventEmitter { * * @param {CrossSigningInfo} userCrossSigning Cross signing info for user * @param {module:crypto/deviceinfo} device The device to check - * @param {bool} localTrust Whether the device is trusted locally - * @param {bool} trustCrossSignedDevices Whether we trust cross signed devices + * @param {boolean} localTrust Whether the device is trusted locally + * @param {boolean} trustCrossSignedDevices Whether we trust cross signed devices * * @returns {DeviceTrustLevel} */ - checkDeviceTrust(userCrossSigning, device, localTrust, trustCrossSignedDevices) { + public checkDeviceTrust( + userCrossSigning: CrossSigningInfo, + device: DeviceInfo, + localTrust: boolean, + trustCrossSignedDevices: boolean, + ): DeviceTrustLevel { const userTrust = this.checkUserTrust(userCrossSigning); const userSSK = userCrossSigning.keys.self_signing; @@ -552,29 +563,23 @@ export class CrossSigningInfo extends EventEmitter { // if we can verify the user's SSK from their master key... pkVerify(userSSK, userCrossSigning.getId(), userCrossSigning.userId); // ...and this device's key from their SSK... - pkVerify( - deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId, - ); + pkVerify(deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId); // ...then we trust this device as much as far as we trust the user - return DeviceTrustLevel.fromUserTrustLevel( - userTrust, localTrust, trustCrossSignedDevices, - ); + return DeviceTrustLevel.fromUserTrustLevel(userTrust, localTrust, trustCrossSignedDevices); } catch (e) { - return new DeviceTrustLevel( - false, false, localTrust, trustCrossSignedDevices, - ); + return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); } } /** * @returns {object} Cache callbacks */ - getCacheCallbacks() { - return this._cacheCallbacks; + public getCacheCallbacks(): ICacheCallbacks { + return this.cacheCallbacks; } } -function deviceToObject(device, userId) { +function deviceToObject(device: DeviceInfo, userId: string) { return { algorithms: device.algorithms, keys: device.keys, @@ -584,49 +589,49 @@ function deviceToObject(device, userId) { }; } -export const CrossSigningLevel = { - MASTER: 4, - USER_SIGNING: 2, - SELF_SIGNING: 1, -}; +export enum CrossSigningLevel { + MASTER = 4, + USER_SIGNING = 2, + SELF_SIGNING = 1, +} /** * Represents the ways in which we trust a user */ export class UserTrustLevel { - constructor(crossSigningVerified, crossSigningVerifiedBefore, tofu) { - this._crossSigningVerified = crossSigningVerified; - this._crossSigningVerifiedBefore = crossSigningVerifiedBefore; - this._tofu = tofu; - } + constructor( + private readonly crossSigningVerified: boolean, + private readonly crossSigningVerifiedBefore: boolean, + private readonly tofu: boolean, + ) {} /** - * @returns {bool} true if this user is verified via any means + * @returns {boolean} true if this user is verified via any means */ - isVerified() { + public isVerified(): boolean { return this.isCrossSigningVerified(); } /** - * @returns {bool} true if this user is verified via cross signing + * @returns {boolean} true if this user is verified via cross signing */ - isCrossSigningVerified() { - return this._crossSigningVerified; + public isCrossSigningVerified(): boolean { + return this.crossSigningVerified; } /** - * @returns {bool} true if we ever verified this user before (at least for + * @returns {boolean} true if we ever verified this user before (at least for * the history of verifications observed by this device). */ - wasCrossSigningVerified() { - return this._crossSigningVerifiedBefore; + public wasCrossSigningVerified(): boolean { + return this.crossSigningVerifiedBefore; } /** - * @returns {bool} true if this user's key is trusted on first use + * @returns {boolean} true if this user's key is trusted on first use */ - isTofu() { - return this._tofu; + public isTofu(): boolean { + return this.tofu; } } @@ -634,58 +639,62 @@ export class UserTrustLevel { * Represents the ways in which we trust a device */ export class DeviceTrustLevel { - constructor(crossSigningVerified, tofu, localVerified, trustCrossSignedDevices) { - this._crossSigningVerified = crossSigningVerified; - this._tofu = tofu; - this._localVerified = localVerified; - this._trustCrossSignedDevices = trustCrossSignedDevices; - } + constructor( + public readonly crossSigningVerified: boolean, + public readonly tofu: boolean, + private readonly localVerified: boolean, + private readonly trustCrossSignedDevices: boolean, + ) {} - static fromUserTrustLevel(userTrustLevel, localVerified, trustCrossSignedDevices) { + public static fromUserTrustLevel( + userTrustLevel: UserTrustLevel, + localVerified: boolean, + trustCrossSignedDevices: boolean, + ): DeviceTrustLevel { return new DeviceTrustLevel( - userTrustLevel._crossSigningVerified, - userTrustLevel._tofu, + userTrustLevel.isCrossSigningVerified(), + userTrustLevel.isTofu(), localVerified, trustCrossSignedDevices, ); } /** - * @returns {bool} true if this device is verified via any means + * @returns {boolean} true if this device is verified via any means */ - isVerified() { + public isVerified(): boolean { return Boolean(this.isLocallyVerified() || ( - this._trustCrossSignedDevices && this.isCrossSigningVerified() + this.trustCrossSignedDevices && this.isCrossSigningVerified() )); } /** - * @returns {bool} true if this device is verified via cross signing + * @returns {boolean} true if this device is verified via cross signing */ - isCrossSigningVerified() { - return this._crossSigningVerified; + public isCrossSigningVerified(): boolean { + return this.crossSigningVerified; } /** - * @returns {bool} true if this device is verified locally + * @returns {boolean} true if this device is verified locally */ - isLocallyVerified() { - return this._localVerified; + public isLocallyVerified(): boolean { + return this.localVerified; } /** - * @returns {bool} true if this device is trusted from a user's key + * @returns {boolean} true if this device is trusted from a user's key * that is trusted on first use */ - isTofu() { - return this._tofu; + public isTofu(): boolean { + return this.tofu; } } -export function createCryptoStoreCacheCallbacks(store, olmdevice) { +export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: OlmDevice): ICacheCallbacks { return { - getCrossSigningKeyCache: async function(type, _expectedPublicKey) { - const key = await new Promise((resolve) => { + getCrossSigningKeyCache: async function(type: string, _expectedPublicKey: string): Promise { + const key = await new Promise((resolve) => { return store.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], @@ -696,20 +705,20 @@ export function createCryptoStoreCacheCallbacks(store, olmdevice) { }); if (key && key.ciphertext) { - const pickleKey = Buffer.from(olmdevice._pickleKey); + const pickleKey = Buffer.from(olmDevice._pickleKey); const decrypted = await decryptAES(key, pickleKey, type); return decodeBase64(decrypted); } else { return key; } }, - storeCrossSigningKeyCache: async function(type, key) { + storeCrossSigningKeyCache: async function(type: string, key: Uint8Array): Promise { if (!(key instanceof Uint8Array)) { throw new Error( `storeCrossSigningKeyCache expects Uint8Array, got ${key}`, ); } - const pickleKey = Buffer.from(olmdevice._pickleKey); + const pickleKey = Buffer.from(olmDevice._pickleKey); key = await encryptAES(encodeBase64(key), pickleKey, type); return store.doTxn( 'readwrite', @@ -729,7 +738,7 @@ export function createCryptoStoreCacheCallbacks(store, olmdevice) { * @param {string} userId The user ID being verified * @param {string} deviceId The device ID being verified */ -export async function requestKeysDuringVerification(baseApis, userId, deviceId) { +export async function requestKeysDuringVerification(baseApis: MatrixClient, userId: string, deviceId: string) { // If this is a self-verification, ask the other party for keys if (baseApis.getUserId() !== userId) { return; @@ -739,7 +748,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 @@ -748,8 +757,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId) const crossSigning = new CrossSigningInfo( original.userId, { getCrossSigningKey: async (type) => { - logger.debug("Cross-signing: requesting secret", - type, deviceId); + logger.debug("Cross-signing: requesting secret", type, deviceId); const { promise } = client.requestSecret( `m.cross_signing.${type}`, [deviceId], ); @@ -757,7 +765,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId) const decoded = decodeBase64(result); return Uint8Array.from(decoded); } }, - original._cacheCallbacks, + original.getCacheCallbacks(), ); crossSigning.keys = original.keys; @@ -774,7 +782,8 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId) }); // also request and cache the key backup key - const backupKeyPromise = new Promise(async resolve => { + // eslint-disable-next-line no-async-promise-executor + const backupKeyPromise = new Promise(async resolve => { const cachedKey = await client.crypto.getSessionBackupPrivateKey(); if (!cachedKey) { logger.info("No cached backup key found. Requesting..."); @@ -791,9 +800,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId) logger.info("Backup key stored. Starting backup restore..."); const backupInfo = await client.getKeyBackupVersion(); // no need to await for this - just let it go in the bg - client.restoreKeyBackupWithCache( - undefined, undefined, backupInfo, - ).then(() => { + client.restoreKeyBackupWithCache(undefined, undefined, backupInfo).then(() => { logger.info("Backup restored."); }); } diff --git a/src/crypto/DeviceList.js b/src/crypto/DeviceList.ts similarity index 62% rename from src/crypto/DeviceList.js rename to src/crypto/DeviceList.ts index 9b74a54db..688e510a9 100644 --- a/src/crypto/DeviceList.js +++ b/src/crypto/DeviceList.ts @@ -1,7 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2017 - 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. @@ -23,12 +21,15 @@ limitations under the License. */ import { EventEmitter } from 'events'; + import { logger } from '../logger'; -import { DeviceInfo } from './deviceinfo'; +import { DeviceInfo, IDevice } from './deviceinfo'; import { CrossSigningInfo } from './CrossSigning'; import * as olmlib from './olmlib'; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; -import { chunkPromises, defer, sleep } from '../utils'; +import { chunkPromises, defer, IDeferred, sleep } from '../utils'; +import { MatrixClient, CryptoStore } from "../client"; +import { OlmDevice } from "./OlmDevice"; /* State transition diagram for DeviceList._deviceTrackingStatus * @@ -51,91 +52,96 @@ import { chunkPromises, defer, sleep } from '../utils'; */ // constants for DeviceList._deviceTrackingStatus -const TRACKING_STATUS_NOT_TRACKED = 0; -const TRACKING_STATUS_PENDING_DOWNLOAD = 1; -const TRACKING_STATUS_DOWNLOAD_IN_PROGRESS = 2; -const TRACKING_STATUS_UP_TO_DATE = 3; +enum TrackingStatus { + NotTracked, + PendingDownload, + DownloadInProgress, + UpToDate, +} + +type DeviceInfoMap = Record>; /** * @alias module:crypto/DeviceList */ export class DeviceList extends EventEmitter { - constructor(baseApis, cryptoStore, olmDevice, keyDownloadChunkSize = 250) { + // userId -> { + // deviceId -> { + // [device info] + // } + // } + private devices: DeviceInfoMap = {}; + + // userId -> { + // [key info] + // } + public crossSigningInfo: Record = {}; + + // map of identity keys to the user who owns it + private userByIdentityKey: Record = {}; + + // which users we are tracking device status for. + // userId -> TRACKING_STATUS_* + private deviceTrackingStatus: Record = {}; // loaded from storage in load() + + // The 'next_batch' sync token at the point the data was written, + // ie. a token representing the point immediately after the + // moment represented by the snapshot in the db. + private syncToken: string = null; + + // userId -> promise + private keyDownloadsInProgressByUser: Record> = {}; + + // Set whenever changes are made other than setting the sync token + private dirty = false; + + // Promise resolved when device data is saved + private savePromise: Promise = null; + // Function that resolves the save promise + private resolveSavePromise: (saved: boolean) => void = null; + // The time the save is scheduled for + private savePromiseTime: number = null; + // The timer used to delay the save + private saveTimer: NodeJS.Timeout = null; + // True if we have fetched data from the server or loaded a non-empty + // set of device data from the store + private hasFetched: boolean = null; + + private readonly serialiser: DeviceListUpdateSerialiser; + + constructor( + baseApis: MatrixClient, + private readonly cryptoStore: CryptoStore, + olmDevice: OlmDevice, + // Maximum number of user IDs per request to prevent server overload (#1619) + public readonly keyDownloadChunkSize = 250, + ) { super(); - this._cryptoStore = cryptoStore; - - // userId -> { - // deviceId -> { - // [device info] - // } - // } - this._devices = {}; - - // userId -> { - // [key info] - // } - this._crossSigningInfo = {}; - - // map of identity keys to the user who owns it - this._userByIdentityKey = {}; - - // which users we are tracking device status for. - // userId -> TRACKING_STATUS_* - this._deviceTrackingStatus = {}; // loaded from storage in load() - - // The 'next_batch' sync token at the point the data was writen, - // ie. a token representing the point immediately after the - // moment represented by the snapshot in the db. - this._syncToken = null; - - this._serialiser = new DeviceListUpdateSerialiser( - baseApis, olmDevice, this, - ); - - // userId -> promise - this._keyDownloadsInProgressByUser = {}; - - // Maximum number of user IDs per request to prevent server overload (#1619) - this._keyDownloadChunkSize = keyDownloadChunkSize; - - // Set whenever changes are made other than setting the sync token - this._dirty = false; - - // Promise resolved when device data is saved - this._savePromise = null; - // Function that resolves the save promise - this._resolveSavePromise = null; - // The time the save is scheduled for - this._savePromiseTime = null; - // The timer used to delay the save - this._saveTimer = null; - // True if we have fetched data from the server or loaded a non-empty - // set of device data from the store - this._hasFetched = null; + this.serialiser = new DeviceListUpdateSerialiser(baseApis, olmDevice, this); } /** * Load the device tracking state from storage */ - async load() { - await this._cryptoStore.doTxn( + public async load() { + await this.cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { - this._cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { - this._hasFetched = Boolean(deviceData && deviceData.devices); - this._devices = deviceData ? deviceData.devices : {}, - this._crossSigningInfo = deviceData ? + this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { + this.hasFetched = Boolean(deviceData && deviceData.devices); + this.devices = deviceData ? deviceData.devices : {}, + this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {}; - this._deviceTrackingStatus = deviceData ? + this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {}; - this._syncToken = deviceData ? deviceData.syncToken : null; - this._userByIdentityKey = {}; - for (const user of Object.keys(this._devices)) { - const userDevices = this._devices[user]; + this.syncToken = deviceData ? deviceData.syncToken : null; + this.userByIdentityKey = {}; + for (const user of Object.keys(this.devices)) { + const userDevices = this.devices[user]; for (const device of Object.keys(userDevices)) { const idKey = userDevices[device].keys['curve25519:'+device]; if (idKey !== undefined) { - this._userByIdentityKey[idKey] = user; + this.userByIdentityKey[idKey] = user; } } } @@ -143,17 +149,17 @@ export class DeviceList extends EventEmitter { }, ); - for (const u of Object.keys(this._deviceTrackingStatus)) { + for (const u of Object.keys(this.deviceTrackingStatus)) { // if a download was in progress when we got shut down, it isn't any more. - if (this._deviceTrackingStatus[u] == TRACKING_STATUS_DOWNLOAD_IN_PROGRESS) { - this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD; + if (this.deviceTrackingStatus[u] == TrackingStatus.DownloadInProgress) { + this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; } } } - stop() { - if (this._saveTimer !== null) { - clearTimeout(this._saveTimer); + public stop() { + if (this.saveTimer !== null) { + clearTimeout(this.saveTimer); } } @@ -164,74 +170,73 @@ export class DeviceList extends EventEmitter { * The actual save will be delayed by a short amount of time to * aggregate multiple writes to the database. * - * @param {integer} delay Time in ms before which the save actually happens. + * @param {number} delay Time in ms before which the save actually happens. * By default, the save is delayed for a short period in order to batch * multiple writes, but this behaviour can be disabled by passing 0. * - * @return {Promise} true if the data was saved, false if + * @return {Promise} true if the data was saved, false if * it was not (eg. because no changes were pending). The promise * will only resolve once the data is saved, so may take some time * to resolve. */ - async saveIfDirty(delay) { - if (!this._dirty) return Promise.resolve(false); + public async saveIfDirty(delay = 500): Promise { + if (!this.dirty) return Promise.resolve(false); // Delay saves for a bit so we can aggregate multiple saves that happen // in quick succession (eg. when a whole room's devices are marked as known) - if (delay === undefined) delay = 500; const targetTime = Date.now + delay; - if (this._savePromiseTime && targetTime < this._savePromiseTime) { + if (this.savePromiseTime && targetTime < this.savePromiseTime) { // There's a save scheduled but for after we would like: cancel // it & schedule one for the time we want - clearTimeout(this._saveTimer); - this._saveTimer = null; - this._savePromiseTime = null; + clearTimeout(this.saveTimer); + this.saveTimer = null; + this.savePromiseTime = null; // (but keep the save promise since whatever called save before // will still want to know when the save is done) } - let savePromise = this._savePromise; + let savePromise = this.savePromise; if (savePromise === null) { savePromise = new Promise((resolve, reject) => { - this._resolveSavePromise = resolve; + this.resolveSavePromise = resolve; }); - this._savePromise = savePromise; + this.savePromise = savePromise; } - if (this._saveTimer === null) { - const resolveSavePromise = this._resolveSavePromise; - this._savePromiseTime = targetTime; - this._saveTimer = setTimeout(() => { - logger.log('Saving device tracking data', this._syncToken); + if (this.saveTimer === null) { + const resolveSavePromise = this.resolveSavePromise; + this.savePromiseTime = targetTime; + this.saveTimer = setTimeout(() => { + logger.log('Saving device tracking data', this.syncToken); // null out savePromise now (after the delay but before the write), // otherwise we could return the existing promise when the save has // actually already happened. - this._savePromiseTime = null; - this._saveTimer = null; - this._savePromise = null; - this._resolveSavePromise = null; + this.savePromiseTime = null; + this.saveTimer = null; + this.savePromise = null; + this.resolveSavePromise = null; - this._cryptoStore.doTxn( + this.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { - this._cryptoStore.storeEndToEndDeviceData({ - devices: this._devices, - crossSigningInfo: this._crossSigningInfo, - trackingStatus: this._deviceTrackingStatus, - syncToken: this._syncToken, + this.cryptoStore.storeEndToEndDeviceData({ + devices: this.devices, + crossSigningInfo: this.crossSigningInfo, + trackingStatus: this.deviceTrackingStatus, + syncToken: this.syncToken, }, txn); }, ).then(() => { - // The device list is considered dirty until the write - // completes. - this._dirty = false; - resolveSavePromise(); + // The device list is considered dirty until the write completes. + this.dirty = false; + resolveSavePromise(true); }, err => { - logger.error('Failed to save device tracking data', this._syncToken); + logger.error('Failed to save device tracking data', this.syncToken); logger.error(err); }); }, delay); } + return savePromise; } @@ -240,8 +245,8 @@ export class DeviceList extends EventEmitter { * * @return {string} The sync token */ - getSyncToken() { - return this._syncToken; + public getSyncToken(): string { + return this.syncToken; } /** @@ -254,8 +259,8 @@ export class DeviceList extends EventEmitter { * * @param {string} st The sync token */ - setSyncToken(st) { - this._syncToken = st; + public setSyncToken(st: string): void { + this.syncToken = st; } /** @@ -263,33 +268,33 @@ export class DeviceList extends EventEmitter { * downloading and storing them if they're not (or if forceDownload is * true). * @param {Array} userIds The users to fetch. - * @param {bool} forceDownload Always download the keys even if cached. + * @param {boolean} forceDownload Always download the keys even if cached. * * @return {Promise} A promise which resolves to a map userId->deviceId->{@link * module:crypto/deviceinfo|DeviceInfo}. */ - downloadKeys(userIds, forceDownload) { + public downloadKeys(userIds: string[], forceDownload: boolean): Promise { const usersToDownload = []; const promises = []; userIds.forEach((u) => { - const trackingStatus = this._deviceTrackingStatus[u]; - if (this._keyDownloadsInProgressByUser[u]) { + const trackingStatus = this.deviceTrackingStatus[u]; + if (this.keyDownloadsInProgressByUser[u]) { // already a key download in progress/queued for this user; its results // will be good enough for us. logger.log( `downloadKeys: already have a download in progress for ` + `${u}: awaiting its result`, ); - promises.push(this._keyDownloadsInProgressByUser[u]); - } else if (forceDownload || trackingStatus != TRACKING_STATUS_UP_TO_DATE) { + promises.push(this.keyDownloadsInProgressByUser[u]); + } else if (forceDownload || trackingStatus != TrackingStatus.UpToDate) { usersToDownload.push(u); } }); if (usersToDownload.length != 0) { logger.log("downloadKeys: downloading for", usersToDownload); - const downloadPromise = this._doKeyDownload(usersToDownload); + const downloadPromise = this.doKeyDownload(usersToDownload); promises.push(downloadPromise); } @@ -298,7 +303,7 @@ export class DeviceList extends EventEmitter { } return Promise.all(promises).then(() => { - return this._getDevicesFromStore(userIds); + return this.getDevicesFromStore(userIds); }); } @@ -309,12 +314,11 @@ export class DeviceList extends EventEmitter { * * @return {Object} userId->deviceId->{@link module:crypto/deviceinfo|DeviceInfo}. */ - _getDevicesFromStore(userIds) { + private getDevicesFromStore(userIds: string[]): DeviceInfoMap { const stored = {}; - const self = this; - userIds.map(function(u) { + userIds.map((u) => { stored[u] = {}; - const devices = self.getStoredDevicesForUser(u) || []; + const devices = this.getStoredDevicesForUser(u) || []; devices.map(function(dev) { stored[u][dev.deviceId] = dev; }); @@ -327,8 +331,8 @@ export class DeviceList extends EventEmitter { * * @return {array} All known user IDs */ - getKnownUserIds() { - return Object.keys(this._devices); + public getKnownUserIds(): string[] { + return Object.keys(this.devices); } /** @@ -339,8 +343,8 @@ export class DeviceList extends EventEmitter { * @return {module:crypto/deviceinfo[]|null} list of devices, or null if we haven't * managed to get a list of devices for this user yet. */ - getStoredDevicesForUser(userId) { - const devs = this._devices[userId]; + public getStoredDevicesForUser(userId: string): DeviceInfo[] | null { + const devs = this.devices[userId]; if (!devs) { return null; } @@ -361,19 +365,19 @@ export class DeviceList extends EventEmitter { * @return {Object} deviceId->{object} devices, or undefined if * there is no data for this user. */ - getRawStoredDevicesForUser(userId) { - return this._devices[userId]; + public getRawStoredDevicesForUser(userId: string): Record { + return this.devices[userId]; } - getStoredCrossSigningForUser(userId) { - if (!this._crossSigningInfo[userId]) return null; + public getStoredCrossSigningForUser(userId: string): CrossSigningInfo { + if (!this.crossSigningInfo[userId]) return null; - return CrossSigningInfo.fromStorage(this._crossSigningInfo[userId], userId); + return CrossSigningInfo.fromStorage(this.crossSigningInfo[userId], userId); } - storeCrossSigningForUser(userId, info) { - this._crossSigningInfo[userId] = info; - this._dirty = true; + public storeCrossSigningForUser(userId: string, info: CrossSigningInfo): void { + this.crossSigningInfo[userId] = info; + this.dirty = true; } /** @@ -385,8 +389,8 @@ export class DeviceList extends EventEmitter { * @return {module:crypto/deviceinfo?} device, or undefined * if we don't know about this device */ - getStoredDevice(userId, deviceId) { - const devs = this._devices[userId]; + public getStoredDevice(userId: string, deviceId: string): DeviceInfo { + const devs = this.devices[userId]; if (!devs || !devs[deviceId]) { return undefined; } @@ -401,7 +405,7 @@ export class DeviceList extends EventEmitter { * * @return {string} user ID */ - getUserByIdentityKey(algorithm, senderKey) { + public getUserByIdentityKey(algorithm: string, senderKey: string): string { if ( algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.MEGOLM_ALGORITHM @@ -410,7 +414,7 @@ export class DeviceList extends EventEmitter { return null; } - return this._userByIdentityKey[senderKey]; + return this.userByIdentityKey[senderKey]; } /** @@ -421,13 +425,13 @@ export class DeviceList extends EventEmitter { * * @return {module:crypto/deviceinfo?} */ - getDeviceByIdentityKey(algorithm, senderKey) { + public getDeviceByIdentityKey(algorithm: string, senderKey: string): DeviceInfo | null { const userId = this.getUserByIdentityKey(algorithm, senderKey); if (!userId) { return null; } - const devices = this._devices[userId]; + const devices = this.devices[userId]; if (!devices) { return null; } @@ -462,25 +466,25 @@ export class DeviceList extends EventEmitter { * @param {string} u The user ID * @param {Object} devs New device info for user */ - storeDevicesForUser(u, devs) { - // remove previous devices from _userByIdentityKey - if (this._devices[u] !== undefined) { - for (const [deviceId, dev] of Object.entries(this._devices[u])) { + public storeDevicesForUser(u: string, devs: Record): void { + // remove previous devices from userByIdentityKey + if (this.devices[u] !== undefined) { + for (const [deviceId, dev] of Object.entries(this.devices[u])) { const identityKey = dev.keys['curve25519:'+deviceId]; - delete this._userByIdentityKey[identityKey]; + delete this.userByIdentityKey[identityKey]; } } - this._devices[u] = devs; + this.devices[u] = devs; // add new ones for (const [deviceId, dev] of Object.entries(devs)) { const identityKey = dev.keys['curve25519:'+deviceId]; - this._userByIdentityKey[identityKey] = u; + this.userByIdentityKey[identityKey] = u; } - this._dirty = true; + this.dirty = true; } /** @@ -492,7 +496,7 @@ export class DeviceList extends EventEmitter { * * @param {String} userId */ - startTrackingDeviceList(userId) { + public startTrackingDeviceList(userId: string): void { // sanity-check the userId. This is mostly paranoia, but if synapse // can't parse the userId we give it as an mxid, it 500s the whole // request and we can never update the device lists again (because @@ -503,12 +507,12 @@ export class DeviceList extends EventEmitter { if (typeof userId !== 'string') { throw new Error('userId must be a string; was '+userId); } - if (!this._deviceTrackingStatus[userId]) { + if (!this.deviceTrackingStatus[userId]) { logger.log('Now tracking device list for ' + userId); - this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD; + this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload; // we don't yet persist the tracking status, since there may be a lot // of calls; we save all data together once the sync is done - this._dirty = true; + this.dirty = true; } } @@ -521,14 +525,14 @@ export class DeviceList extends EventEmitter { * * @param {String} userId */ - stopTrackingDeviceList(userId) { - if (this._deviceTrackingStatus[userId]) { + public stopTrackingDeviceList(userId: string): void { + if (this.deviceTrackingStatus[userId]) { logger.log('No longer tracking device list for ' + userId); - this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED; + this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked; // we don't yet persist the tracking status, since there may be a lot // of calls; we save all data together once the sync is done - this._dirty = true; + this.dirty = true; } } @@ -538,11 +542,11 @@ export class DeviceList extends EventEmitter { * This will flag each user whose devices we are tracking as in need of an * update. */ - stopTrackingAllDeviceLists() { - for (const userId of Object.keys(this._deviceTrackingStatus)) { - this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED; + public stopTrackingAllDeviceLists(): void { + for (const userId of Object.keys(this.deviceTrackingStatus)) { + this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked; } - this._dirty = true; + this.dirty = true; } /** @@ -556,14 +560,14 @@ export class DeviceList extends EventEmitter { * * @param {String} userId */ - invalidateUserDeviceList(userId) { - if (this._deviceTrackingStatus[userId]) { + public invalidateUserDeviceList(userId: string): void { + if (this.deviceTrackingStatus[userId]) { logger.log("Marking device list outdated for", userId); - this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD; + this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload; // we don't yet persist the tracking status, since there may be a lot // of calls; we save all data together once the sync is done - this._dirty = true; + this.dirty = true; } } @@ -573,18 +577,18 @@ export class DeviceList extends EventEmitter { * @returns {Promise} which completes when the download completes; normally there * is no need to wait for this (it's mostly for the unit tests). */ - refreshOutdatedDeviceLists() { + public refreshOutdatedDeviceLists(): Promise { this.saveIfDirty(); const usersToDownload = []; - for (const userId of Object.keys(this._deviceTrackingStatus)) { - const stat = this._deviceTrackingStatus[userId]; - if (stat == TRACKING_STATUS_PENDING_DOWNLOAD) { + for (const userId of Object.keys(this.deviceTrackingStatus)) { + const stat = this.deviceTrackingStatus[userId]; + if (stat == TrackingStatus.PendingDownload) { usersToDownload.push(userId); } } - return this._doKeyDownload(usersToDownload); + return this.doKeyDownload(usersToDownload); } /** @@ -595,34 +599,34 @@ export class DeviceList extends EventEmitter { * * @param {Object} devices deviceId->{object} the new devices */ - _setRawStoredDevicesForUser(userId, devices) { - // remove old devices from _userByIdentityKey - if (this._devices[userId] !== undefined) { - for (const [deviceId, dev] of Object.entries(this._devices[userId])) { + public setRawStoredDevicesForUser(userId: string, devices: Record): void { + // remove old devices from userByIdentityKey + if (this.devices[userId] !== undefined) { + for (const [deviceId, dev] of Object.entries(this.devices[userId])) { const identityKey = dev.keys['curve25519:'+deviceId]; - delete this._userByIdentityKey[identityKey]; + delete this.userByIdentityKey[identityKey]; } } - this._devices[userId] = devices; + this.devices[userId] = devices; - // add new devices into _userByIdentityKey + // add new devices into userByIdentityKey for (const [deviceId, dev] of Object.entries(devices)) { const identityKey = dev.keys['curve25519:'+deviceId]; - this._userByIdentityKey[identityKey] = userId; + this.userByIdentityKey[identityKey] = userId; } } - setRawStoredCrossSigningForUser(userId, info) { - this._crossSigningInfo[userId] = info; + public setRawStoredCrossSigningForUser(userId: string, info: object): void { + this.crossSigningInfo[userId] = info; } /** * Fire off download update requests for the given users, and update the * device list tracking status for them, and the - * _keyDownloadsInProgressByUser map for them. + * keyDownloadsInProgressByUser map for them. * * @param {String[]} users list of userIds * @@ -630,15 +634,13 @@ export class DeviceList extends EventEmitter { * been updated. rejects if there was a problem updating any of the * users. */ - _doKeyDownload(users) { + private doKeyDownload(users: string[]): Promise { if (users.length === 0) { // nothing to do return Promise.resolve(); } - const prom = this._serialiser.updateDevicesForUsers( - users, this._syncToken, - ).then(() => { + const prom = this.serialiser.updateDevicesForUsers(users, this.syncToken).then(() => { finished(true); }, (e) => { logger.error( @@ -649,42 +651,41 @@ export class DeviceList extends EventEmitter { }); users.forEach((u) => { - this._keyDownloadsInProgressByUser[u] = prom; - const stat = this._deviceTrackingStatus[u]; - if (stat == TRACKING_STATUS_PENDING_DOWNLOAD) { - this._deviceTrackingStatus[u] = TRACKING_STATUS_DOWNLOAD_IN_PROGRESS; + this.keyDownloadsInProgressByUser[u] = prom; + const stat = this.deviceTrackingStatus[u]; + if (stat == TrackingStatus.PendingDownload) { + this.deviceTrackingStatus[u] = TrackingStatus.DownloadInProgress; } }); const finished = (success) => { - this.emit("crypto.willUpdateDevices", users, !this._hasFetched); + this.emit("crypto.willUpdateDevices", users, !this.hasFetched); users.forEach((u) => { - this._dirty = true; + this.dirty = true; // we may have queued up another download request for this user // since we started this request. If that happens, we should // ignore the completion of the first one. - if (this._keyDownloadsInProgressByUser[u] !== prom) { - logger.log('Another update in the queue for', u, - '- not marking up-to-date'); + if (this.keyDownloadsInProgressByUser[u] !== prom) { + logger.log('Another update in the queue for', u, '- not marking up-to-date'); return; } - delete this._keyDownloadsInProgressByUser[u]; - const stat = this._deviceTrackingStatus[u]; - if (stat == TRACKING_STATUS_DOWNLOAD_IN_PROGRESS) { + delete this.keyDownloadsInProgressByUser[u]; + const stat = this.deviceTrackingStatus[u]; + if (stat == TrackingStatus.DownloadInProgress) { if (success) { // we didn't get any new invalidations since this download started: // this user's device list is now up to date. - this._deviceTrackingStatus[u] = TRACKING_STATUS_UP_TO_DATE; + this.deviceTrackingStatus[u] = TrackingStatus.UpToDate; logger.log("Device list for", u, "now up to date"); } else { - this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD; + this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; } } }); this.saveIfDirty(); - this.emit("crypto.devicesUpdated", users, !this._hasFetched); - this._hasFetched = true; + this.emit("crypto.devicesUpdated", users, !this.hasFetched); + this.hasFetched = true; }; return prom; @@ -701,29 +702,28 @@ export class DeviceList extends EventEmitter { * time (and queuing other requests up). */ class DeviceListUpdateSerialiser { + private downloadInProgress = false; + + // users which are queued for download + // userId -> true + private keyDownloadsQueuedByUser: Record = {}; + + // deferred which is resolved when the queued users are downloaded. + // non-null indicates that we have users queued for download. + private queuedQueryDeferred: IDeferred = null; + + private syncToken: string = null; // The sync token we send with the requests + /* * @param {object} baseApis Base API object * @param {object} olmDevice The Olm Device - * @param {object} deviceList The device list object + * @param {object} deviceList The device list object, the device list to be updated */ - constructor(baseApis, olmDevice, deviceList) { - this._baseApis = baseApis; - this._olmDevice = olmDevice; - this._deviceList = deviceList; // the device list to be updated - - this._downloadInProgress = false; - - // users which are queued for download - // userId -> true - this._keyDownloadsQueuedByUser = {}; - - // deferred which is resolved when the queued users are downloaded. - // - // non-null indicates that we have users queued for download. - this._queuedQueryDeferred = null; - - this._syncToken = null; // The sync token we send with the requests - } + constructor( + private readonly baseApis: MatrixClient, + private readonly olmDevice: OlmDevice, + private readonly deviceList: DeviceList, + ) {} /** * Make a key query request for the given users @@ -737,57 +737,57 @@ class DeviceListUpdateSerialiser { * been updated. rejects if there was a problem updating any of the * users. */ - updateDevicesForUsers(users, syncToken) { + public updateDevicesForUsers(users: string[], syncToken: string): Promise { users.forEach((u) => { - this._keyDownloadsQueuedByUser[u] = true; + this.keyDownloadsQueuedByUser[u] = true; }); - if (!this._queuedQueryDeferred) { - this._queuedQueryDeferred = defer(); + if (!this.queuedQueryDeferred) { + this.queuedQueryDeferred = defer(); } // We always take the new sync token and just use the latest one we've // been given, since it just needs to be at least as recent as the // sync response the device invalidation message arrived in - this._syncToken = syncToken; + this.syncToken = syncToken; - if (this._downloadInProgress) { + if (this.downloadInProgress) { // just queue up these users logger.log('Queued key download for', users); - return this._queuedQueryDeferred.promise; + return this.queuedQueryDeferred.promise; } // start a new download. - return this._doQueuedQueries(); + return this.doQueuedQueries(); } - _doQueuedQueries() { - if (this._downloadInProgress) { + private doQueuedQueries(): Promise { + if (this.downloadInProgress) { throw new Error( - "DeviceListUpdateSerialiser._doQueuedQueries called with request active", + "DeviceListUpdateSerialiser.doQueuedQueries called with request active", ); } - const downloadUsers = Object.keys(this._keyDownloadsQueuedByUser); - this._keyDownloadsQueuedByUser = {}; - const deferred = this._queuedQueryDeferred; - this._queuedQueryDeferred = null; + const downloadUsers = Object.keys(this.keyDownloadsQueuedByUser); + this.keyDownloadsQueuedByUser = {}; + const deferred = this.queuedQueryDeferred; + this.queuedQueryDeferred = null; logger.log('Starting key download for', downloadUsers); - this._downloadInProgress = true; + this.downloadInProgress = true; - const opts = {}; - if (this._syncToken) { - opts.token = this._syncToken; + const opts: Parameters[1] = {}; + if (this.syncToken) { + opts.token = this.syncToken; } const factories = []; - for (let i = 0; i < downloadUsers.length; i += this._deviceList._keyDownloadChunkSize) { - const userSlice = downloadUsers.slice(i, i + this._deviceList._keyDownloadChunkSize); - factories.push(() => this._baseApis.downloadKeysForUsers(userSlice, opts)); + for (let i = 0; i < downloadUsers.length; i += this.deviceList.keyDownloadChunkSize) { + const userSlice = downloadUsers.slice(i, i + this.deviceList.keyDownloadChunkSize); + factories.push(() => this.baseApis.downloadKeysForUsers(userSlice, opts)); } - chunkPromises(factories, 3).then(async (responses) => { + chunkPromises(factories, 3).then(async (responses: any[]) => { const dk = Object.assign({}, ...(responses.map(res => res.device_keys || {}))); const masterKeys = Object.assign({}, ...(responses.map(res => res.master_keys || {}))); const ssks = Object.assign({}, ...(responses.map(res => res.self_signing_keys || {}))); @@ -802,7 +802,7 @@ class DeviceListUpdateSerialiser { for (const userId of downloadUsers) { await sleep(5); try { - await this._processQueryResponseForUser( + await this.processQueryResponseForUser( userId, dk[userId], { master: masterKeys[userId], self_signing: ssks[userId], @@ -818,32 +818,34 @@ class DeviceListUpdateSerialiser { }).then(() => { logger.log('Completed key download for ' + downloadUsers); - this._downloadInProgress = false; + this.downloadInProgress = false; deferred.resolve(); // if we have queued users, fire off another request. - if (this._queuedQueryDeferred) { - this._doQueuedQueries(); + if (this.queuedQueryDeferred) { + this.doQueuedQueries(); } }, (e) => { logger.warn('Error downloading keys for ' + downloadUsers + ':', e); - this._downloadInProgress = false; + this.downloadInProgress = false; deferred.reject(e); }); return deferred.promise; } - async _processQueryResponseForUser( - userId, dkResponse, crossSigningResponse, - ) { + private async processQueryResponseForUser( + userId: string, + dkResponse: object, + crossSigningResponse: any, // TODO types + ): Promise { logger.log('got device keys for ' + userId + ':', dkResponse); logger.log('got cross-signing keys for ' + userId + ':', crossSigningResponse); { // map from deviceid -> deviceinfo for this user - const userStore = {}; - const devs = this._deviceList.getRawStoredDevicesForUser(userId); + const userStore: Record = {}; + const devs = this.deviceList.getRawStoredDevicesForUser(userId); if (devs) { Object.keys(devs).forEach((deviceId) => { const d = DeviceInfo.fromStorage(devs[deviceId], deviceId); @@ -851,9 +853,9 @@ class DeviceListUpdateSerialiser { }); } - await _updateStoredDeviceKeysForUser( - this._olmDevice, userId, userStore, dkResponse || {}, - this._baseApis.getUserId(), this._baseApis.deviceId, + await updateStoredDeviceKeysForUser( + this.olmDevice, userId, userStore, dkResponse || {}, + this.baseApis.getUserId(), this.baseApis.deviceId, ); // put the updates into the object that will be returned as our results @@ -862,7 +864,7 @@ class DeviceListUpdateSerialiser { storage[deviceId] = userStore[deviceId].toStorage(); }); - this._deviceList._setRawStoredDevicesForUser(userId, storage); + this.deviceList.setRawStoredDevicesForUser(userId, storage); } // now do the same for the cross-signing keys @@ -873,26 +875,31 @@ class DeviceListUpdateSerialiser { && (crossSigningResponse.master || crossSigningResponse.self_signing || crossSigningResponse.user_signing)) { const crossSigning - = this._deviceList.getStoredCrossSigningForUser(userId) + = this.deviceList.getStoredCrossSigningForUser(userId) || new CrossSigningInfo(userId); crossSigning.setKeys(crossSigningResponse); - this._deviceList.setRawStoredCrossSigningForUser( + this.deviceList.setRawStoredCrossSigningForUser( userId, crossSigning.toStorage(), ); // NB. Unlike most events in the js-sdk, this one is internal to the // js-sdk and is not re-emitted - this._deviceList.emit('userCrossSigningUpdated', userId); + this.deviceList.emit('userCrossSigningUpdated', userId); } } } } -async function _updateStoredDeviceKeysForUser( - _olmDevice, userId, userStore, userResult, localUserId, localDeviceId, -) { +async function updateStoredDeviceKeysForUser( + olmDevice: OlmDevice, + userId: string, + userStore: Record, + userResult: object, + localUserId: string, + localDeviceId: string, +): Promise { let updated = false; // remove any devices in the store which aren't in the response @@ -936,7 +943,7 @@ async function _updateStoredDeviceKeysForUser( continue; } - if (await _storeDeviceKeys(_olmDevice, userStore, deviceResult)) { + if (await storeDeviceKeys(olmDevice, userStore, deviceResult)) { updated = true; } } @@ -949,7 +956,11 @@ async function _updateStoredDeviceKeysForUser( * * returns (a promise for) true if a change was made, else false */ -async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) { +async function storeDeviceKeys( + olmDevice: OlmDevice, + userStore: Record, + deviceResult: any, // TODO types +): Promise { if (!deviceResult.keys) { // no keys? return false; @@ -961,8 +972,7 @@ async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) { const signKeyId = "ed25519:" + deviceId; const signKey = deviceResult.keys[signKeyId]; if (!signKey) { - logger.warn("Device " + userId + ":" + deviceId + - " has no ed25519 key"); + logger.warn("Device " + userId + ":" + deviceId + " has no ed25519 key"); return false; } @@ -970,10 +980,9 @@ async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) { const signatures = deviceResult.signatures || {}; try { - await olmlib.verifySignature(_olmDevice, deviceResult, userId, deviceId, signKey); + await olmlib.verifySignature(olmDevice, deviceResult, userId, deviceId, signKey); } catch (e) { - logger.warn("Unable to verify signature on device " + - userId + ":" + deviceId + ":" + e); + logger.warn("Unable to verify signature on device " + userId + ":" + deviceId + ":" + e); return false; } diff --git a/src/crypto/EncryptionSetup.js b/src/crypto/EncryptionSetup.js index d7f861a03..8da305824 100644 --- a/src/crypto/EncryptionSetup.js +++ b/src/crypto/EncryptionSetup.js @@ -184,7 +184,7 @@ export class EncryptionSetupOperation { }); // pass the new keys to the main instance of our own CrossSigningInfo. - crypto._crossSigningInfo.setKeys(this._crossSigningKeys.keys); + crypto.crossSigningInfo.setKeys(this._crossSigningKeys.keys); } // set account data if (this._accountData) { diff --git a/src/crypto/RoomList.js b/src/crypto/RoomList.ts similarity index 52% rename from src/crypto/RoomList.js rename to src/crypto/RoomList.ts index b00445247..ab653f456 100644 --- a/src/crypto/RoomList.js +++ b/src/crypto/RoomList.ts @@ -1,5 +1,5 @@ /* -Copyright 2018, 2019 New Vector Ltd +Copyright 2018 - 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. @@ -21,44 +21,51 @@ limitations under the License. */ import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { CryptoStore } from "../client"; + +/* eslint-disable camelcase */ +interface IRoomEncryption { + algorithm: string; + rotation_period_ms: number; + rotation_period_msgs: number; +} +/* eslint-enable camelcase */ /** * @alias module:crypto/RoomList */ export class RoomList { - constructor(cryptoStore) { - this._cryptoStore = cryptoStore; + // Object of roomId -> room e2e info object (body of the m.room.encryption event) + private roomEncryption: Record = {}; - // Object of roomId -> room e2e info object (body of the m.room.encryption event) - this._roomEncryption = {}; - } + constructor(private readonly cryptoStore: CryptoStore) {} - async init() { - await this._cryptoStore.doTxn( + public async init(): Promise { + await this.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { - this._cryptoStore.getEndToEndRooms(txn, (result) => { - this._roomEncryption = result; + this.cryptoStore.getEndToEndRooms(txn, (result) => { + this.roomEncryption = result; }); }, ); } - getRoomEncryption(roomId) { - return this._roomEncryption[roomId] || null; + public getRoomEncryption(roomId: string): IRoomEncryption { + return this.roomEncryption[roomId] || null; } - isRoomEncrypted(roomId) { + public isRoomEncrypted(roomId: string): boolean { return Boolean(this.getRoomEncryption(roomId)); } - async setRoomEncryption(roomId, roomInfo) { + public async setRoomEncryption(roomId: string, roomInfo: IRoomEncryption): Promise { // important that this happens before calling into the store // as it prevents the Crypto::setRoomEncryption from calling // this twice for consecutive m.room.encryption events - this._roomEncryption[roomId] = roomInfo; - await this._cryptoStore.doTxn( + this.roomEncryption[roomId] = roomInfo; + await this.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { - this._cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn); + this.cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn); }, ); } diff --git a/src/crypto/SecretStorage.js b/src/crypto/SecretStorage.js index a1ed647f0..f57c61cb1 100644 --- a/src/crypto/SecretStorage.js +++ b/src/crypto/SecretStorage.js @@ -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(), ); diff --git a/src/crypto/dehydration.ts b/src/crypto/dehydration.ts index eb003f75b..73c3cd536 100644 --- a/src/crypto/dehydration.ts +++ b/src/crypto/dehydration.ts @@ -64,16 +64,16 @@ export class DehydrationManager { this.getDehydrationKeyFromCache(); } async getDehydrationKeyFromCache(): Promise { - return await this.crypto._cryptoStore.doTxn( + return await this.crypto.cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto._cryptoStore.getSecretStorePrivateKey( + this.crypto.cryptoStore.getSecretStorePrivateKey( txn, async (result) => { if (result) { const { key, keyInfo, deviceDisplayName, time } = result; - const pickleKey = Buffer.from(this.crypto._olmDevice._pickleKey); + const pickleKey = Buffer.from(this.crypto.olmDevice._pickleKey); const decrypted = await decryptAES(key, pickleKey, DEHYDRATION_ALGORITHM); this.key = decodeBase64(decrypted); this.keyInfo = keyInfo; @@ -114,11 +114,11 @@ export class DehydrationManager { this.timeoutId = undefined; } // clear storage - await this.crypto._cryptoStore.doTxn( + await this.crypto.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto._cryptoStore.storeSecretStorePrivateKey( + this.crypto.cryptoStore.storeSecretStorePrivateKey( txn, "dehydration", null, ); }, @@ -158,15 +158,15 @@ export class DehydrationManager { this.timeoutId = undefined; } try { - const pickleKey = Buffer.from(this.crypto._olmDevice._pickleKey); + const pickleKey = Buffer.from(this.crypto.olmDevice._pickleKey); // update the crypto store with the timestamp const key = await encryptAES(encodeBase64(this.key), pickleKey, DEHYDRATION_ALGORITHM); - await this.crypto._cryptoStore.doTxn( + await this.crypto.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto._cryptoStore.storeSecretStorePrivateKey( + this.crypto.cryptoStore.storeSecretStorePrivateKey( txn, "dehydration", { keyInfo: this.keyInfo, @@ -205,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", @@ -223,9 +223,9 @@ export class DehydrationManager { const deviceId = dehydrateResult.device_id; logger.log("Preparing device keys", deviceId); const deviceKeys: DeviceKeys = { - algorithms: this.crypto._supportedAlgorithms, + algorithms: this.crypto.supportedAlgorithms, device_id: deviceId, - user_id: this.crypto._userId, + user_id: this.crypto.userId, keys: { [`ed25519:${deviceId}`]: e2eKeys.ed25519, [`curve25519:${deviceId}`]: e2eKeys.curve25519, @@ -233,12 +233,12 @@ export class DehydrationManager { }; const deviceSignature = account.sign(anotherjson.stringify(deviceKeys)); deviceKeys.signatures = { - [this.crypto._userId]: { + [this.crypto.userId]: { [`ed25519:${deviceId}`]: deviceSignature, }, }; - if (this.crypto._crossSigningInfo.getId("self_signing")) { - await this.crypto._crossSigningInfo.signObject(deviceKeys, "self_signing"); + if (this.crypto.crossSigningInfo.getId("self_signing")) { + await this.crypto.crossSigningInfo.signObject(deviceKeys, "self_signing"); } logger.log("Preparing one-time keys"); @@ -247,7 +247,7 @@ export class DehydrationManager { const k: OneTimeKey = { key }; const signature = account.sign(anotherjson.stringify(k)); k.signatures = { - [this.crypto._userId]: { + [this.crypto.userId]: { [`ed25519:${deviceId}`]: signature, }, }; @@ -260,7 +260,7 @@ export class DehydrationManager { const k: OneTimeKey = { key, fallback: true }; const signature = account.sign(anotherjson.stringify(k)); k.signatures = { - [this.crypto._userId]: { + [this.crypto.userId]: { [`ed25519:${deviceId}`]: signature, }, }; @@ -268,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), diff --git a/src/crypto/deviceinfo.js b/src/crypto/deviceinfo.js deleted file mode 100644 index 379d72c63..000000000 --- a/src/crypto/deviceinfo.js +++ /dev/null @@ -1,168 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 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/deviceinfo - */ - -/** - * Information about a user's device - * - * @constructor - * @alias module:crypto/deviceinfo - * - * @property {string} deviceId the ID of this device - * - * @property {string[]} algorithms list of algorithms supported by this device - * - * @property {Object.} keys a map from - * <key type>:<id> -> <base64-encoded key>> - * - * @property {module:crypto/deviceinfo.DeviceVerification} verified - * whether the device has been verified/blocked by the user - * - * @property {boolean} known - * whether the user knows of this device's existence (useful when warning - * the user that a user has added new devices) - * - * @property {Object} unsigned additional data from the homeserver - * - * @param {string} deviceId id of the device - */ -export function DeviceInfo(deviceId) { - // you can't change the deviceId - Object.defineProperty(this, 'deviceId', { - enumerable: true, - value: deviceId, - }); - - this.algorithms = []; - this.keys = {}; - this.verified = DeviceVerification.UNVERIFIED; - this.known = false; - this.unsigned = {}; - this.signatures = {}; -} - -/** - * rehydrate a DeviceInfo from the session store - * - * @param {object} obj raw object from session store - * @param {string} deviceId id of the device - * - * @return {module:crypto~DeviceInfo} new DeviceInfo - */ -DeviceInfo.fromStorage = function(obj, deviceId) { - const res = new DeviceInfo(deviceId); - for (const prop in obj) { - if (obj.hasOwnProperty(prop)) { - res[prop] = obj[prop]; - } - } - return res; -}; - -/** - * Prepare a DeviceInfo for JSON serialisation in the session store - * - * @return {object} deviceinfo with non-serialised members removed - */ -DeviceInfo.prototype.toStorage = function() { - return { - algorithms: this.algorithms, - keys: this.keys, - verified: this.verified, - known: this.known, - unsigned: this.unsigned, - signatures: this.signatures, - }; -}; - -/** - * Get the fingerprint for this device (ie, the Ed25519 key) - * - * @return {string} base64-encoded fingerprint of this device - */ -DeviceInfo.prototype.getFingerprint = function() { - return this.keys["ed25519:" + this.deviceId]; -}; - -/** - * Get the identity key for this device (ie, the Curve25519 key) - * - * @return {string} base64-encoded identity key of this device - */ -DeviceInfo.prototype.getIdentityKey = function() { - return this.keys["curve25519:" + this.deviceId]; -}; - -/** - * Get the configured display name for this device, if any - * - * @return {string?} displayname - */ -DeviceInfo.prototype.getDisplayName = function() { - return this.unsigned.device_display_name || null; -}; - -/** - * Returns true if this device is blocked - * - * @return {Boolean} true if blocked - */ -DeviceInfo.prototype.isBlocked = function() { - return this.verified == DeviceVerification.BLOCKED; -}; - -/** - * Returns true if this device is verified - * - * @return {Boolean} true if verified - */ -DeviceInfo.prototype.isVerified = function() { - return this.verified == DeviceVerification.VERIFIED; -}; - -/** - * Returns true if this device is unverified - * - * @return {Boolean} true if unverified - */ -DeviceInfo.prototype.isUnverified = function() { - return this.verified == DeviceVerification.UNVERIFIED; -}; - -/** - * Returns true if the user knows about this device's existence - * - * @return {Boolean} true if known - */ -DeviceInfo.prototype.isKnown = function() { - return this.known == true; -}; - -/** - * @enum - */ -DeviceInfo.DeviceVerification = { - VERIFIED: 1, - UNVERIFIED: 0, - BLOCKED: -1, -}; - -const DeviceVerification = DeviceInfo.DeviceVerification; - diff --git a/src/crypto/deviceinfo.ts b/src/crypto/deviceinfo.ts new file mode 100644 index 000000000..d723eac4b --- /dev/null +++ b/src/crypto/deviceinfo.ts @@ -0,0 +1,175 @@ +/* +Copyright 2016 - 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/deviceinfo + */ + +export interface IDevice { + keys: Record; + algorithms: string[]; + verified: DeviceVerification; + known: boolean; + unsigned?: Record; + signatures?: Record; +} + +enum DeviceVerification { + Blocked = -1, + Unverified = 0, + Verified = 1, +} + +/** + * Information about a user's device + * + * @constructor + * @alias module:crypto/deviceinfo + * + * @property {string} deviceId the ID of this device + * + * @property {string[]} algorithms list of algorithms supported by this device + * + * @property {Object.} keys a map from + * <key type>:<id> -> <base64-encoded key>> + * + * @property {module:crypto/deviceinfo.DeviceVerification} verified + * whether the device has been verified/blocked by the user + * + * @property {boolean} known + * whether the user knows of this device's existence (useful when warning + * the user that a user has added new devices) + * + * @property {Object} unsigned additional data from the homeserver + * + * @param {string} deviceId id of the device + */ +export class DeviceInfo { + /** + * rehydrate a DeviceInfo from the session store + * + * @param {object} obj raw object from session store + * @param {string} deviceId id of the device + * + * @return {module:crypto~DeviceInfo} new DeviceInfo + */ + public static fromStorage(obj: IDevice, deviceId: string): DeviceInfo { + const res = new DeviceInfo(deviceId); + for (const prop in obj) { + if (obj.hasOwnProperty(prop)) { + res[prop] = obj[prop]; + } + } + return res; + } + + /** + * @enum + */ + public static DeviceVerification = { + VERIFIED: DeviceVerification.Verified, + UNVERIFIED: DeviceVerification.Unverified, + BLOCKED: DeviceVerification.Blocked, + }; + + public algorithms: string[]; + public keys: Record = {}; + public verified = DeviceVerification.Unverified; + public known = false; + public unsigned: Record = {}; + public signatures: Record = {}; + + constructor(public readonly deviceId: string) {} + + /** + * Prepare a DeviceInfo for JSON serialisation in the session store + * + * @return {object} deviceinfo with non-serialised members removed + */ + public toStorage(): IDevice { + return { + algorithms: this.algorithms, + keys: this.keys, + verified: this.verified, + known: this.known, + unsigned: this.unsigned, + signatures: this.signatures, + }; + } + + /** + * Get the fingerprint for this device (ie, the Ed25519 key) + * + * @return {string} base64-encoded fingerprint of this device + */ + public getFingerprint(): string { + return this.keys["ed25519:" + this.deviceId]; + } + + /** + * Get the identity key for this device (ie, the Curve25519 key) + * + * @return {string} base64-encoded identity key of this device + */ + public getIdentityKey(): string { + return this.keys["curve25519:" + this.deviceId]; + } + + /** + * Get the configured display name for this device, if any + * + * @return {string?} displayname + */ + public getDisplayName(): string | null { + return this.unsigned.device_display_name || null; + } + + /** + * Returns true if this device is blocked + * + * @return {Boolean} true if blocked + */ + public isBlocked(): boolean { + return this.verified == DeviceVerification.Blocked; + } + + /** + * Returns true if this device is verified + * + * @return {Boolean} true if verified + */ + public isVerified(): boolean { + return this.verified == DeviceVerification.Verified; + } + + /** + * Returns true if this device is unverified + * + * @return {Boolean} true if unverified + */ + public isUnverified(): boolean { + return this.verified == DeviceVerification.Unverified; + } + + /** + * Returns true if the user knows about this device's existence + * + * @return {Boolean} true if known + */ + public isKnown(): boolean { + return this.known === true; + } +} diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 205a21952..038708f86 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -29,7 +29,7 @@ import { logger } from '../logger'; import { OlmDevice } from "./OlmDevice"; import * as olmlib from "./olmlib"; import { DeviceList } from "./DeviceList"; -import { DeviceInfo } from "./deviceinfo"; +import { DeviceInfo, IDevice } from "./deviceinfo"; import * as algorithms from "./algorithms"; import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from './CrossSigning'; import { EncryptionSetupBuilder } from "./EncryptionSetup"; @@ -420,9 +420,7 @@ export class Crypto extends EventEmitter { }; myDevices[this.deviceId] = deviceInfo; - this.deviceList.storeDevicesForUser( - this.userId, myDevices, - ); + this.deviceList.storeDevicesForUser(this.userId, myDevices); this.deviceList.saveIfDirty(); } @@ -922,10 +920,7 @@ export class Crypto extends EventEmitter { await this.crossSigningInfo.getCrossSigningKeysFromCache(); // This is writing to in-memory account data in // builder.accountDataClientAdapter so won't fail - await CrossSigningInfo.storeInSecretStorage( - crossSigningPrivateKeys, - secretStorage, - ); + await CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage); } if (setupNewKeyBackup && !keyBackupInfo) { @@ -1172,7 +1167,7 @@ export class Crypto extends EventEmitter { // FIXME: do this in batches const users = {}; for (const [userId, crossSigningInfo] - of Object.entries(this.deviceList._crossSigningInfo)) { + of Object.entries(this.deviceList.crossSigningInfo)) { const upgradeInfo = await this.checkForDeviceVerificationUpgrade( userId, CrossSigningInfo.fromStorage(crossSigningInfo, userId), ); @@ -1248,7 +1243,7 @@ export class Crypto extends EventEmitter { private async checkForValidDeviceSignature( userId: string, key: any, // TODO types - devices: Record, + devices: Record, ): Promise { const deviceIds: string[] = []; if (devices && key.signatures && key.signatures[userId]) { @@ -1934,7 +1929,7 @@ export class Crypto extends EventEmitter { public downloadKeys( userIds: string[], forceDownload?: boolean, - ): Promise>> { + ): Promise>> { return this.deviceList.downloadKeys(userIds, forceDownload); } @@ -2003,7 +1998,7 @@ export class Crypto extends EventEmitter { verified?: boolean, blocked?: boolean, known?: boolean, - ): Promise { + ): Promise { // get rid of any `undefined`s here so we can just check // for null rather than null or undefined if (verified === undefined) verified = null; @@ -2068,7 +2063,7 @@ export class Crypto extends EventEmitter { // This will emit events when it comes back down the sync // (we could do local echo to speed things up) } - return device; + return device as any; // TODO types } else { return xsk; } diff --git a/src/crypto/key_passphrase.js b/src/crypto/key_passphrase.ts similarity index 76% rename from src/crypto/key_passphrase.js rename to src/crypto/key_passphrase.ts index 84384cc4e..3631fe30f 100644 --- a/src/crypto/key_passphrase.js +++ b/src/crypto/key_passphrase.ts @@ -1,6 +1,5 @@ /* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2018 - 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. @@ -21,7 +20,21 @@ const DEFAULT_ITERATIONS = 500000; const DEFAULT_BITSIZE = 256; -export async function keyFromAuthData(authData, password) { +/* eslint-disable camelcase */ +interface IAuthData { + private_key_salt: string; + private_key_iterations: number; + private_key_bits?: number; +} +/* eslint-enable camelcase */ + +interface IKey { + key: Uint8Array; + salt: string; + iterations: number +} + +export async function keyFromAuthData(authData: IAuthData, password: string): Promise { if (!global.Olm) { throw new Error("Olm is not available"); } @@ -40,7 +53,7 @@ export async function keyFromAuthData(authData, password) { ); } -export async function keyFromPassphrase(password) { +export async function keyFromPassphrase(password: string): Promise { if (!global.Olm) { throw new Error("Olm is not available"); } @@ -52,7 +65,12 @@ export async function keyFromPassphrase(password) { return { key, salt, iterations: DEFAULT_ITERATIONS }; } -export async function deriveKey(password, salt, iterations, numBits = DEFAULT_BITSIZE) { +export async function deriveKey( + password: string, + salt: string, + iterations: number, + numBits = DEFAULT_BITSIZE, +): Promise { const subtleCrypto = global.crypto.subtle; const TextEncoder = global.TextEncoder; if (!subtleCrypto || !TextEncoder) { diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index c2fe3a1ce..8376245cd 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -31,17 +31,22 @@ export interface IKeyBackupRoomSessions { [sessionId: string]: IKeyBackupSession; } +/* eslint-disable camelcase */ export interface IKeyBackupVersion { algorithm: string; - auth_data: { // eslint-disable-line camelcase - public_key: string; // eslint-disable-line camelcase + auth_data: { + public_key: string; signatures: ISignatures; + private_key_salt: string; + private_key_iterations: number; + private_key_bits?: number; }; count: number; etag: string; version: string; // number contained within - recovery_key: string; // eslint-disable-line camelcase + recovery_key: string; } +/* eslint-enable camelcase */ export interface IKeyBackupPrepareOpts { secureSecretStorage: boolean; diff --git a/src/crypto/verification/Base.js b/src/crypto/verification/Base.js index 2ac17cc88..4ca01bd11 100644 --- a/src/crypto/verification/Base.js +++ b/src/crypto/verification/Base.js @@ -292,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({ diff --git a/src/matrix.ts b/src/matrix.ts index c9192f2ea..2cce6867a 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -19,6 +19,7 @@ import { MemoryStore } from "./store/memory"; import { MatrixScheduler } from "./scheduler"; import { MatrixClient } from "./client"; import { ICreateClientOpts } from "./client"; +import { DeviceTrustLevel } from "./crypto/CrossSigning"; export * from "./client"; export * from "./http-api"; @@ -99,7 +100,7 @@ export function setCryptoStoreFactory(fac) { } export interface ICryptoCallbacks { - getCrossSigningKey?: (keyType: string, pubKey: Uint8Array) => Promise; + getCrossSigningKey?: (keyType: string, pubKey: string) => Promise; saveCrossSigningKeys?: (keys: Record) => void; shouldUpgradeDeviceVerifications?: ( users: Record @@ -112,7 +113,7 @@ export interface ICryptoCallbacks { ) => void; onSecretRequested?: ( userId: string, deviceId: string, - requestId: string, secretName: string, deviceTrust: IDeviceTrustLevel + requestId: string, secretName: string, deviceTrust: DeviceTrustLevel ) => Promise; getDehydrationKey?: ( keyInfo: ISecretStorageKeyInfo, @@ -132,14 +133,6 @@ export interface ISecretStorageKeyInfo { mac?: string; } -// TODO: Move this to `CrossSigning` once converted -export interface IDeviceTrustLevel { - isVerified(): boolean; - isCrossSigningVerified(): boolean; - isLocallyVerified(): boolean; - isTofu(): boolean; -} - /** * Construct a Matrix Client. Similar to {@link module:client.MatrixClient} * except that the 'request', 'store' and 'scheduler' dependencies are satisfied. diff --git a/src/utils.ts b/src/utils.ts index 14207ebca..587d9a7f9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -435,12 +435,18 @@ export function isNullOrUndefined(val: any): boolean { return val === null || val === undefined; } +export interface IDeferred { + resolve: (value: T) => void; + reject: (any) => void; + promise: Promise; +} + // Returns a Deferred -export function defer() { +export function defer(): IDeferred { let resolve; let reject; - const promise = new Promise((_resolve, _reject) => { + const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); From 664d920dd17554c530652aa68d316e85640b1c4e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 23 Jun 2021 14:51:40 +0100 Subject: [PATCH 094/124] Fix issues identified by Typescriptification --- src/crypto/DeviceList.ts | 2 +- src/crypto/index.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/crypto/DeviceList.ts b/src/crypto/DeviceList.ts index 688e510a9..3c330994f 100644 --- a/src/crypto/DeviceList.ts +++ b/src/crypto/DeviceList.ts @@ -184,7 +184,7 @@ export class DeviceList extends EventEmitter { // Delay saves for a bit so we can aggregate multiple saves that happen // in quick succession (eg. when a whole room's devices are marked as known) - const targetTime = Date.now + delay; + const targetTime = Date.now() + delay; if (this.savePromiseTime && targetTime < this.savePromiseTime) { // There's a save scheduled but for after we would like: cancel // it & schedule one for the time we want diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 038708f86..14b37e322 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -1216,7 +1216,7 @@ export class Crypto extends EventEmitter { // only upgrade if this is the first cross-signing key that we've seen for // them, and if their cross-signing key isn't already verified const trustLevel = this.crossSigningInfo.checkUserTrust(crossSigningInfo); - if (crossSigningInfo.firstUse && !trustLevel.verified) { + if (crossSigningInfo.firstUse && !trustLevel.isVerified()) { const devices = this.deviceList.getRawStoredDevicesForUser(userId); const deviceIds = await this.checkForValidDeviceSignature( userId, crossSigningInfo.keys.master, devices, @@ -2908,9 +2908,6 @@ export class Crypto extends EventEmitter { this.deviceList.setSyncToken(syncData.nextSyncToken); this.deviceList.saveIfDirty(); - // catch up on any new devices we got told about during the sync. - this.deviceList.lastKnownSyncToken = nextSyncToken; - // we always track our own device list (for key backups etc) this.deviceList.startTrackingDeviceList(this.userId); From e9007429dd14c236017fd79300600e8b1a02d185 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 23 Jun 2021 15:02:01 +0100 Subject: [PATCH 095/124] fix more underscored accesses --- spec/unit/crypto/algorithms/megolm.spec.js | 2 +- spec/unit/crypto/backup.spec.js | 6 +++--- spec/unit/crypto/crypto-utils.js | 10 +++++----- src/crypto/EncryptionSetup.js | 9 ++++----- src/crypto/algorithms/megolm.js | 10 +++++----- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index b3afc3e6c..6c6034b72 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -257,7 +257,7 @@ describe("MegolmDecryption", function() { }); it("re-uses sessions for sequential messages", async function() { - mockCrypto._backupManager = { + mockCrypto.backupManager = { backupGroupSession: () => {}, }; const mockStorage = new MockStorageApi(); diff --git a/spec/unit/crypto/backup.spec.js b/spec/unit/crypto/backup.spec.js index df65475d4..ae6b25ddf 100644 --- a/spec/unit/crypto/backup.spec.js +++ b/spec/unit/crypto/backup.spec.js @@ -139,7 +139,7 @@ describe("MegolmBackup", function() { let megolmDecryption; beforeEach(async function() { mockCrypto = testUtils.mock(Crypto, 'Crypto'); - mockCrypto._backupManager = testUtils.mock(BackupManager, "BackupManager"); + mockCrypto.backupManager = testUtils.mock(BackupManager, "BackupManager"); mockCrypto.backupKey = new Olm.PkEncryption(); mockCrypto.backupKey.set_recipient_key( "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", @@ -217,14 +217,14 @@ describe("MegolmBackup", function() { }; mockCrypto.cancelRoomKeyRequest = function() {}; - mockCrypto._backupManager = { + mockCrypto.backupManager = { backupGroupSession: jest.fn(), }; return event.attemptDecryption(mockCrypto).then(() => { return megolmDecryption.onRoomKeyEvent(event); }).then(() => { - expect(mockCrypto._backupManager.backupGroupSession).toHaveBeenCalled(); + expect(mockCrypto.backupManager.backupGroupSession).toHaveBeenCalled(); }); }); diff --git a/spec/unit/crypto/crypto-utils.js b/spec/unit/crypto/crypto-utils.js index dbf6cec65..b54b1a18e 100644 --- a/spec/unit/crypto/crypto-utils.js +++ b/spec/unit/crypto/crypto-utils.js @@ -11,12 +11,12 @@ export async function resetCrossSigningKeys(client, { const oldKeys = Object.assign({}, crypto.crossSigningInfo.keys); try { await crypto.crossSigningInfo.resetKeys(level); - await crypto._signObject(crypto.crossSigningInfo.keys.master); + await crypto.signObject(crypto.crossSigningInfo.keys.master); // write a copy locally so we know these are trusted keys - await crypto._cryptoStore.doTxn( + await crypto.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - crypto._cryptoStore.storeCrossSigningKeys( + crypto.cryptoStore.storeCrossSigningKeys( txn, crypto.crossSigningInfo.keys); }, ); @@ -26,8 +26,8 @@ export async function resetCrossSigningKeys(client, { crypto.crossSigningInfo.keys = oldKeys; throw e; } - crypto._baseApis.emit("crossSigning.keysChanged", {}); - await crypto._afterCrossSigningLocalKeyChange(); + crypto.baseApis.emit("crossSigning.keysChanged", {}); + await crypto.afterCrossSigningLocalKeyChange(); } export async function createSecretStorageKey() { diff --git a/src/crypto/EncryptionSetup.js b/src/crypto/EncryptionSetup.js index 8da305824..1a7fcf36a 100644 --- a/src/crypto/EncryptionSetup.js +++ b/src/crypto/EncryptionSetup.js @@ -121,18 +121,17 @@ export class EncryptionSetupBuilder { async persist(crypto) { // store private keys in cache if (this._crossSigningKeys) { - const cacheCallbacks = createCryptoStoreCacheCallbacks( - crypto._cryptoStore, crypto._olmDevice); + const cacheCallbacks = createCryptoStoreCacheCallbacks(crypto.cryptoStore, crypto.olmDevice); for (const type of ["master", "self_signing", "user_signing"]) { logger.log(`Cache ${type} cross-signing private key locally`); const privateKey = this.crossSigningCallbacks.privateKeys.get(type); await cacheCallbacks.storeCrossSigningKeyCache(type, privateKey); } // store own cross-sign pubkeys as trusted - await crypto._cryptoStore.doTxn( + await crypto.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - crypto._cryptoStore.storeCrossSigningKeys( + crypto.cryptoStore.storeCrossSigningKeys( txn, this._crossSigningKeys.keys); }, ); @@ -169,7 +168,7 @@ export class EncryptionSetupOperation { * @param {Crypto} crypto */ async apply(crypto) { - const baseApis = crypto._baseApis; + const baseApis = crypto.baseApis; // upload cross-signing keys if (this._crossSigningKeys) { const keys = {}; diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index e6d6e0b62..f457e6e6d 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -413,7 +413,7 @@ MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) { ); // don't wait for it to complete - this._crypto._backupManager.backupGroupSession( + this._crypto.backupManager.backupGroupSession( this._olmDevice.deviceCurve25519Key, sessionId, ); @@ -1424,7 +1424,7 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { }); }).then(() => { // don't wait for the keys to be backed up for the server - this._crypto._backupManager.backupGroupSession(senderKey, content.session_id); + this._crypto.backupManager.backupGroupSession(senderKey, content.session_id); }).catch((e) => { logger.error(`Error handling m.room_key_event: ${e}`); }); @@ -1460,14 +1460,14 @@ MegolmDecryption.prototype.onRoomKeyWithheldEvent = async function(event) { this.retryDecryptionFromSender(senderKey); return; } - let device = this._crypto._deviceList.getDeviceByIdentityKey( + let device = this._crypto.deviceList.getDeviceByIdentityKey( content.algorithm, senderKey, ); if (!device) { // if we don't know about the device, fetch the user's devices again // and retry before giving up await this._crypto.downloadKeys([sender], false); - device = this._crypto._deviceList.getDeviceByIdentityKey( + device = this._crypto.deviceList.getDeviceByIdentityKey( content.algorithm, senderKey, ); if (!device) { @@ -1640,7 +1640,7 @@ MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) { ).then(() => { if (opts.source !== "backup") { // don't wait for it to complete - this._crypto._backupManager.backupGroupSession( + this._crypto.backupManager.backupGroupSession( session.sender_key, session.session_id, ).catch((e) => { // This throws if the upload failed, but this is fine From 02afcc7d4b04461004c77076b44eca77eae39ad7 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 23 Jun 2021 15:48:21 +0100 Subject: [PATCH 096/124] delint --- src/crypto/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 14b37e322..930f5fbb6 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -2903,8 +2903,6 @@ export class Crypto extends EventEmitter { * @param {Object} syncData the data from the 'MatrixClient.sync' event */ public async onSyncCompleted(syncData: ISyncData): Promise { - const nextSyncToken = syncData.nextSyncToken; - this.deviceList.setSyncToken(syncData.nextSyncToken); this.deviceList.saveIfDirty(); From 3a5e4ffa91eb10974d2f6c17ebebe678e8b4cb27 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 23 Jun 2021 15:54:48 +0100 Subject: [PATCH 097/124] Fix yet more underscored accesses --- spec/unit/crypto/algorithms/megolm.spec.js | 4 ++-- spec/unit/crypto/cross-signing.spec.js | 4 ++-- .../crypto/verification/secret_request.spec.js | 14 +++++++------- src/crypto/DeviceList.ts | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index 6c6034b72..e7d63c6a2 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -408,7 +408,7 @@ describe("MegolmDecryption", function() { "@bob:example.com", BOB_DEVICES, ); aliceClient.crypto.deviceList.downloadKeys = async function(userIds) { - return this._getDevicesFromStore(userIds); + return this.getDevicesFromStore(userIds); }; let run = false; @@ -512,7 +512,7 @@ describe("MegolmDecryption", function() { "@bob:example.com", BOB_DEVICES, ); aliceClient.crypto.deviceList.downloadKeys = async function(userIds) { - return this._getDevicesFromStore(userIds); + return this.getDevicesFromStore(userIds); }; aliceClient.claimOneTimeKeys = async () => { diff --git a/spec/unit/crypto/cross-signing.spec.js b/spec/unit/crypto/cross-signing.spec.js index 3118e6365..8638c1f4d 100644 --- a/spec/unit/crypto/cross-signing.spec.js +++ b/spec/unit/crypto/cross-signing.spec.js @@ -222,7 +222,7 @@ describe("Cross Signing", function() { }); }); - 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", @@ -444,7 +444,7 @@ describe("Cross Signing", function() { }); }); - 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", diff --git a/spec/unit/crypto/verification/secret_request.spec.js b/spec/unit/crypto/verification/secret_request.spec.js index 8c9573273..f261afacf 100644 --- a/spec/unit/crypto/verification/secret_request.spec.js +++ b/spec/unit/crypto/verification/secret_request.spec.js @@ -48,18 +48,18 @@ describe("self-verifications", () => { storeCrossSigningKeyCache: jest.fn(), }; - const _crossSigningInfo = new CrossSigningInfo( + const crossSigningInfo = new CrossSigningInfo( userId, {}, cacheCallbacks, ); - _crossSigningInfo.keys = { + crossSigningInfo.keys = { master: { keys: { X: testKeyPub } }, self_signing: { keys: { X: testKeyPub } }, user_signing: { keys: { X: testKeyPub } }, }; - const _secretStorage = { + const secretStorage = { request: jest.fn().mockReturnValue({ promise: Promise.resolve(encodeBase64(testKey)), }), @@ -70,12 +70,12 @@ describe("self-verifications", () => { const client = { crypto: { - _crossSigningInfo, - _secretStorage, + crossSigningInfo, + secretStorage, storeSessionBackupPrivateKey, getSessionBackupPrivateKey: () => null, }, - requestSecret: _secretStorage.request.bind(_secretStorage), + requestSecret: secretStorage.request.bind(secretStorage), getUserId: () => userId, getKeyBackupVersion: () => Promise.resolve({}), restoreKeyBackupWithCache, @@ -99,7 +99,7 @@ describe("self-verifications", () => { /* We should request, and store, 3 cross signing keys and the key backup key */ expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls.length).toBe(3); - expect(_secretStorage.request.mock.calls.length).toBe(4); + expect(secretStorage.request.mock.calls.length).toBe(4); expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[0][1]) .toEqual(testKey); diff --git a/src/crypto/DeviceList.ts b/src/crypto/DeviceList.ts index 3c330994f..f5ec71d1a 100644 --- a/src/crypto/DeviceList.ts +++ b/src/crypto/DeviceList.ts @@ -31,7 +31,7 @@ import { chunkPromises, defer, IDeferred, sleep } from '../utils'; import { MatrixClient, CryptoStore } from "../client"; import { OlmDevice } from "./OlmDevice"; -/* State transition diagram for DeviceList._deviceTrackingStatus +/* State transition diagram for DeviceList.deviceTrackingStatus * * | * stopTrackingDeviceList V @@ -51,7 +51,7 @@ import { OlmDevice } from "./OlmDevice"; * +----------------------- UP_TO_DATE ------------------------+ */ -// constants for DeviceList._deviceTrackingStatus +// constants for DeviceList.deviceTrackingStatus enum TrackingStatus { NotTracked, PendingDownload, From b4dc1e1555b9ccb65e77951fdeae7860a668839a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 24 Jun 2021 17:41:52 +0100 Subject: [PATCH 098/124] Moar typescriptification --- src/client.ts | 16 ++++++-------- src/crypto/CrossSigning.ts | 6 ++--- src/crypto/aes.ts | 35 +++++++++++++++-------------- src/crypto/backup.ts | 45 +++++++++++++++++++++----------------- src/crypto/index.ts | 21 +++++------------- src/crypto/keybackup.ts | 1 - src/crypto/olmlib.ts | 26 ++++++++++++---------- src/crypto/recoverykey.ts | 2 +- 8 files changed, 74 insertions(+), 78 deletions(-) diff --git a/src/client.ts b/src/client.ts index eff9a9c3c..5e06cc4e1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -114,7 +114,7 @@ import url from "url"; import { randomString } from "./randomstring"; import { ReadStream } from "fs"; import { WebStorageSessionStore } from "./store/session/webstorage"; -import { BackupManager, IKeyBackupCheck, TrustInfo } from "./crypto/backup"; +import { BackupManager, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; export type Store = StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; @@ -2180,7 +2180,7 @@ export class MatrixClient extends EventEmitter { public async prepareKeyBackupVersion( password: string, opts: IKeyBackupPrepareOpts = { secureSecretStorage: false }, - ): Promise { + ): Promise> { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2198,7 +2198,7 @@ export class MatrixClient extends EventEmitter { algorithm, auth_data, recovery_key, - } as any; // TODO: Types + }; } /** @@ -2313,7 +2313,7 @@ export class MatrixClient extends EventEmitter { * Back up session keys to the homeserver. * @param {string} roomId ID of the room that the keys are for Optional. * @param {string} sessionId ID of the session that the keys are for Optional. - * @param {integer} version backup version Optional. + * @param {number} version backup version Optional. * @param {object} data Object keys to send * @return {Promise} a promise that will resolve when the keys * are uploaded @@ -2388,7 +2388,7 @@ export class MatrixClient extends EventEmitter { * @param {string} recoveryKey The recovery key * @return {Uint8Array} key backup key */ - public keyBackupKeyFromRecoveryKey(recoveryKey: string): Uint8Array { + public keyBackupKeyFromRecoveryKey(recoveryKey: string): ArrayLike { return decodeRecoveryKey(recoveryKey); } @@ -2478,9 +2478,7 @@ export class MatrixClient extends EventEmitter { opts: IKeyBackupRestoreOpts, ): Promise { const privKey = decodeRecoveryKey(recoveryKey); - return this.restoreKeyBackup( - privKey, targetRoomId, targetSessionId, backupInfo, opts, - ); + return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts); } // TODO: Types @@ -2498,7 +2496,7 @@ export class MatrixClient extends EventEmitter { } private async restoreKeyBackup( - privKey: Uint8Array, + privKey: ArrayLike, targetRoomId: string, targetSessionId: string, backupInfo: IKeyBackupVersion, diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index 2d983c2e5..7ce177922 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -296,7 +296,7 @@ export class CrossSigningInfo extends EventEmitter { } const privateKeys: Record = {}; - const keys: Record = {}; + const keys: Record = {}; // TODO types let masterSigning; let masterPub; @@ -719,12 +719,12 @@ export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: O ); } const pickleKey = Buffer.from(olmDevice._pickleKey); - key = await encryptAES(encodeBase64(key), pickleKey, type); + const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, type); return store.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - store.storeSecretStorePrivateKey(txn, type, key); + store.storeSecretStorePrivateKey(txn, type, encryptedKey); }, ); }, diff --git a/src/crypto/aes.ts b/src/crypto/aes.ts index 1188b9585..3cc465419 100644 --- a/src/crypto/aes.ts +++ b/src/crypto/aes.ts @@ -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. @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { BinaryLike } from "crypto"; + import { getCrypto } from '../utils'; import { decodeBase64, encodeBase64 } from './olmlib'; @@ -23,6 +25,12 @@ const subtleCrypto = (typeof window !== "undefined" && window.crypto) ? // salt for HKDF, with 8 bytes of zeros const zeroSalt = new Uint8Array(8); +interface IEncryptedPayload { + iv: string; + ciphertext: string; + mac: string; +} + /** * encrypt a string in Node.js * @@ -31,7 +39,7 @@ const zeroSalt = new Uint8Array(8); * @param {string} name the name of the secret * @param {string} ivStr the initialization vector to use */ -async function encryptNode(data: string, key: Uint8Array, name: string, ivStr?: string) { +async function encryptNode(data: string, key: Uint8Array, name: string, ivStr?: string): Promise { const crypto = getCrypto(); if (!crypto) { throw new Error("No usable crypto implementation"); @@ -77,7 +85,7 @@ async function encryptNode(data: string, key: Uint8Array, name: string, ivStr?: * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret */ -async function decryptNode(data: IData, key: Uint8Array, name: string) { +async function decryptNode(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { const crypto = getCrypto(); if (!crypto) { throw new Error("No usable crypto implementation"); @@ -100,10 +108,9 @@ async function decryptNode(data: IData, key: Uint8Array, name: string) { + decipher.final("utf8"); } -function deriveKeysNode(key, name) { +function deriveKeysNode(key: BinaryLike, name: string): [Buffer, Buffer] { const crypto = getCrypto(); - const prk = crypto.createHmac("sha256", zeroSalt) - .update(key).digest(); + const prk = crypto.createHmac("sha256", zeroSalt).update(key).digest(); const b = Buffer.alloc(1, 1); const aesKey = crypto.createHmac("sha256", prk) @@ -123,7 +130,7 @@ function deriveKeysNode(key, name) { * @param {string} name the name of the secret * @param {string} ivStr the initialization vector to use */ -async function encryptBrowser(data: string, key: Uint8Array, name: string, ivStr?: string) { +async function encryptBrowser(data: string, key: Uint8Array, name: string, ivStr?: string): Promise { let iv; if (ivStr) { iv = decodeBase64(ivStr); @@ -163,12 +170,6 @@ async function encryptBrowser(data: string, key: Uint8Array, name: string, ivStr }; } -interface IData { - ciphertext: string; - iv: string; - mac: string; -} - /** * decrypt a string in the browser * @@ -179,7 +180,7 @@ interface IData { * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret */ -async function decryptBrowser(data: IData, key: Uint8Array, name: string) { +async function decryptBrowser(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { const [aesKey, hmacKey] = await deriveKeysBrowser(key, name); const ciphertext = decodeBase64(data.ciphertext); @@ -206,7 +207,7 @@ async function decryptBrowser(data: IData, key: Uint8Array, name: string) { return new TextDecoder().decode(new Uint8Array(plaintext)); } -async function deriveKeysBrowser(key, name) { +async function deriveKeysBrowser(key: Uint8Array, name: string): Promise<[CryptoKey, CryptoKey]> { const hkdfkey = await subtleCrypto.importKey( 'raw', key, @@ -252,11 +253,11 @@ async function deriveKeysBrowser(key, name) { return await Promise.all([aesProm, hmacProm]); } -export function encryptAES(data: string, key: Uint8Array, name: string, ivStr?: string) { +export function encryptAES(data: string, key: Uint8Array, name: string, ivStr?: string): Promise { return subtleCrypto ? encryptBrowser(data, key, name, ivStr) : encryptNode(data, key, name, ivStr); } -export function decryptAES(data: IData, key: Uint8Array, name: string) { +export function decryptAES(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { return subtleCrypto ? decryptBrowser(data, key, name) : decryptNode(data, key, name); } diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index f2b62cc01..3d5485f27 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -29,16 +29,11 @@ import { keyFromPassphrase } from './key_passphrase'; import { sleep } from "../utils"; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; import { encodeRecoveryKey } from './recoverykey'; +import { IKeyBackupVersion } from "./keybackup"; const KEY_BACKUP_KEYS_PER_REQUEST = 200; -type AuthData = Record; - -type BackupInfo = { - algorithm: string, - auth_data: AuthData, // eslint-disable-line camelcase - [properties: string]: any, -}; +type AuthData = IKeyBackupVersion["auth_data"]; type SigInfo = { deviceId: string, @@ -54,13 +49,22 @@ export type TrustInfo = { }; export interface IKeyBackupCheck { - backupInfo: BackupInfo; + backupInfo: IKeyBackupVersion; trustInfo: TrustInfo; } +/* eslint-disable camelcase */ +export interface IPreparedKeyBackupVersion { + algorithm: string; + auth_data: AuthData; + recovery_key: string; + privateKey: Uint8Array; +} +/* eslint-enable camelcase */ + /** A function used to get the secret key for a backup. */ -type GetKey = () => Promise; +type GetKey = () => Promise>; interface BackupAlgorithmClass { algorithmName: string; @@ -77,7 +81,7 @@ interface BackupAlgorithm { encryptSession(data: Record): Promise; decryptSessions(ciphertexts: Record): Promise[]>; authData: AuthData; - keyMatches(key: Uint8Array): Promise; + keyMatches(key: ArrayLike): Promise; free(): void; } @@ -86,7 +90,7 @@ interface BackupAlgorithm { */ export class BackupManager { private algorithm: BackupAlgorithm | undefined; - public backupInfo: BackupInfo | undefined; // The info dict from /room_keys/version + public backupInfo: IKeyBackupVersion | 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) { @@ -98,7 +102,7 @@ export class BackupManager { return this.backupInfo && this.backupInfo.version; } - public static async makeAlgorithm(info: BackupInfo, getKey: GetKey): Promise { + public static async makeAlgorithm(info: IKeyBackupVersion, getKey: GetKey): Promise { const Algorithm = algorithmsByName[info.algorithm]; if (!Algorithm) { throw new Error("Unknown backup algorithm"); @@ -106,7 +110,7 @@ export class BackupManager { return await Algorithm.init(info.auth_data, getKey); } - public async enableKeyBackup(info: BackupInfo): Promise { + public async enableKeyBackup(info: IKeyBackupVersion): Promise { this.backupInfo = info; if (this.algorithm) { this.algorithm.free(); @@ -145,7 +149,8 @@ export class BackupManager { public async prepareKeyBackupVersion( key?: string | Uint8Array | null, algorithm?: string | undefined, - ): Promise { + // eslint-disable-next-line camelcase + ): Promise { const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm; if (!Algorithm) { throw new Error("Unknown backup algorithm"); @@ -161,7 +166,7 @@ export class BackupManager { }; } - public async createKeyBackupVersion(info: BackupInfo): Promise { + public async createKeyBackupVersion(info: IKeyBackupVersion): Promise { this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); } @@ -171,14 +176,14 @@ export class BackupManager { * one of the user's verified devices, start backing up * to it. */ - public async checkAndStart(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> { + public async checkAndStart(): Promise { 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; + let backupInfo: IKeyBackupVersion; try { backupInfo = await this.baseApis.getKeyBackupVersion(); } catch (e) { @@ -255,7 +260,7 @@ export class BackupManager { * ] * } */ - public async isKeyBackupTrusted(backupInfo: BackupInfo): Promise { + public async isKeyBackupTrusted(backupInfo: IKeyBackupVersion): Promise { const ret = { usable: false, trusted_locally: false, @@ -569,7 +574,7 @@ export class Curve25519 implements BackupAlgorithm { ): Promise<[Uint8Array, AuthData]> { const decryption = new global.Olm.PkDecryption(); try { - const authData: AuthData = {}; + const authData: Partial = {}; if (!key) { authData.public_key = decryption.generate_key(); } else if (key instanceof Uint8Array) { @@ -585,7 +590,7 @@ export class Curve25519 implements BackupAlgorithm { return [ decryption.get_private_key(), - authData, + authData as AuthData, ]; } finally { decryption.free(); diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 930f5fbb6..5752f9df6 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -138,11 +138,6 @@ interface IDeviceVerificationUpgrade { * could be established */ -interface IOlmSessionResult { - device: DeviceInfo; - sessionId?: string; -} - interface IUserOlmSession { deviceIdKey: string; sessions: { @@ -1079,17 +1074,17 @@ export class Crypto extends EventEmitter { * @param {Uint8Array} key the private key * @returns {Promise} so you can catch failures */ - public async storeSessionBackupPrivateKey(key: Uint8Array): Promise { + public async storeSessionBackupPrivateKey(key: ArrayLike): Promise { if (!(key instanceof Uint8Array)) { throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`); } const pickleKey = Buffer.from(this.olmDevice._pickleKey); - key = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); + const encryptedKey = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); return this.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", key); + this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey); }, ); } @@ -2573,7 +2568,7 @@ export class Crypto extends EventEmitter { * an Object mapping from userId to deviceId to * {@link module:crypto~OlmSessionResult} */ - ensureOlmSessionsForUsers(users: string[]): Promise { + ensureOlmSessionsForUsers(users: string[]): Promise>> { const devicesByUser = {}; for (let i = 0; i < users.length; ++i) { @@ -2598,9 +2593,7 @@ export class Crypto extends EventEmitter { } } - return olmlib.ensureOlmSessionsForDevices( - this.olmDevice, this.baseApis, devicesByUser, - ); + return olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); } /** @@ -3259,9 +3252,7 @@ export class Crypto extends EventEmitter { } const devicesByUser = {}; devicesByUser[sender] = [device]; - await olmlib.ensureOlmSessionsForDevices( - this.olmDevice, this.baseApis, devicesByUser, true, - ); + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, true); this.lastNewSessionForced[sender][deviceKey] = Date.now(); diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index 8376245cd..20ca4f146 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -44,7 +44,6 @@ export interface IKeyBackupVersion { count: number; etag: string; version: string; // number contained within - recovery_key: string; } /* eslint-enable camelcase */ diff --git a/src/crypto/olmlib.ts b/src/crypto/olmlib.ts index a61058624..646f330ec 100644 --- a/src/crypto/olmlib.ts +++ b/src/crypto/olmlib.ts @@ -52,6 +52,11 @@ export const MEGOLM_ALGORITHM = Algorithm.Megolm; */ export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup; +export interface IOlmSessionResult { + device: DeviceInfo; + sessionId?: string; +} + /** * Encrypt an event payload for an Olm device * @@ -209,11 +214,11 @@ export async function ensureOlmSessionsForDevices( olmDevice: OlmDevice, baseApis: MatrixClient, devicesByUser: Record, - force: boolean, - otkTimeout: number, - failedServers: string[], - log: Logger, -) { + force = false, + otkTimeout?: number, + failedServers?: string[], + log: Logger = logger, +): Promise>> { if (typeof force === "number") { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - backwards compatibility @@ -224,9 +229,6 @@ export async function ensureOlmSessionsForDevices( otkTimeout = force; force = false; } - if (!log) { - log = logger; - } const devicesWithoutSession = [ // [userId, deviceId], ... @@ -439,9 +441,9 @@ async function _verifyKeyAndStartSession( return sid; } -interface IObject { - unsigned: object; - signatures: object; +export interface IObject { + unsigned?: object; + signatures?: object; } /** @@ -555,7 +557,7 @@ export function pkVerify(obj: IObject, pubKey: string, userId: string) { * @param {Uint8Array} uint8Array The data to encode. * @return {string} The base64. */ -export function encodeBase64(uint8Array: ArrayBuffer): string { +export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string { return Buffer.from(uint8Array).toString("base64"); } diff --git a/src/crypto/recoverykey.ts b/src/crypto/recoverykey.ts index 94a5ae8e2..5c54e6085 100644 --- a/src/crypto/recoverykey.ts +++ b/src/crypto/recoverykey.ts @@ -35,7 +35,7 @@ export function encodeRecoveryKey(key: ArrayLike): string { return base58key.match(/.{1,4}/g).join(" "); } -export function decodeRecoveryKey(recoveryKey: string): ArrayLike { +export function decodeRecoveryKey(recoveryKey: string): Uint8Array { const result = bs58.decode(recoveryKey.replace(/ /g, '')); let parity = 0; From 40aa6ba96af5f469d6731d5a6b1cfe7b75d656c6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 24 Jun 2021 19:19:41 +0100 Subject: [PATCH 099/124] Even moar typescriptification --- .../crypto/outgoing-room-key-requests.spec.js | 18 +- src/client.ts | 68 +++- src/crypto/CrossSigning.ts | 23 +- ...{EncryptionSetup.js => EncryptionSetup.ts} | 195 +++++----- ...er.js => OutgoingRoomKeyRequestManager.ts} | 343 +++++++++--------- src/crypto/aes.ts | 2 +- src/crypto/api.ts | 4 +- src/crypto/backup.ts | 18 +- src/crypto/deviceinfo.ts | 2 + src/crypto/index.ts | 23 +- src/crypto/keybackup.ts | 8 +- src/crypto/olmlib.ts | 2 +- src/crypto/store/{base.js => base.ts} | 11 + 13 files changed, 389 insertions(+), 328 deletions(-) rename src/crypto/{EncryptionSetup.js => EncryptionSetup.ts} (61%) rename src/crypto/{OutgoingRoomKeyRequestManager.js => OutgoingRoomKeyRequestManager.ts} (54%) rename src/crypto/store/{base.js => base.ts} (72%) diff --git a/spec/unit/crypto/outgoing-room-key-requests.spec.js b/spec/unit/crypto/outgoing-room-key-requests.spec.js index a1fa62a70..24b9325b4 100644 --- a/spec/unit/crypto/outgoing-room-key-requests.spec.js +++ b/spec/unit/crypto/outgoing-room-key-requests.spec.js @@ -21,25 +21,23 @@ import { MemoryCryptoStore } from '../../../src/crypto/store/memory-crypto-store import 'fake-indexeddb/auto'; import 'jest-localstorage-mock'; -import { - ROOM_KEY_REQUEST_STATES, -} from '../../../src/crypto/OutgoingRoomKeyRequestManager'; +import { RoomKeyRequestState } from '../../../src/crypto/OutgoingRoomKeyRequestManager'; const requests = [ { requestId: "A", requestBody: { session_id: "A", room_id: "A" }, - state: ROOM_KEY_REQUEST_STATES.SENT, + state: RoomKeyRequestState.Sent, }, { requestId: "B", requestBody: { session_id: "B", room_id: "B" }, - state: ROOM_KEY_REQUEST_STATES.SENT, + state: RoomKeyRequestState.Sent, }, { requestId: "C", requestBody: { session_id: "C", room_id: "C" }, - state: ROOM_KEY_REQUEST_STATES.UNSENT, + state: RoomKeyRequestState.Unsent, }, ]; @@ -68,9 +66,9 @@ describe.each([ it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", async () => { const r = await - store.getAllOutgoingRoomKeyRequestsByState(ROOM_KEY_REQUEST_STATES.SENT); + store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); expect(r).toHaveLength(2); - requests.filter((e) => e.state == ROOM_KEY_REQUEST_STATES.SENT).forEach((e) => { + requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => { expect(r).toContainEqual(e); }); }); @@ -78,10 +76,10 @@ describe.each([ test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", async () => { const r = - await store.getOutgoingRoomKeyRequestByState([ROOM_KEY_REQUEST_STATES.SENT]); + await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]); expect(r).not.toBeNull(); expect(r).not.toBeUndefined(); - expect(r.state).toEqual(ROOM_KEY_REQUEST_STATES.SENT); + expect(r.state).toEqual(RoomKeyRequestState.Sent); expect(requests).toContainEqual(r); }); }); diff --git a/src/client.ts b/src/client.ts index 5e06cc4e1..32874e2e1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -59,7 +59,7 @@ import { IKeyBackupPrepareOpts, IKeyBackupRestoreOpts, IKeyBackupRestoreResult, - IKeyBackupVersion, + IKeyBackupInfo, } from "./crypto/keybackup"; import { IIdentityServerProvider } from "./@types/IIdentityServerProvider"; import type Request from "request"; @@ -116,6 +116,7 @@ import { ReadStream } from "fs"; import { WebStorageSessionStore } from "./store/session/webstorage"; import { BackupManager, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; +import { ISignatures } from "./@types/signed"; export type Store = StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; export type SessionStore = WebStorageSessionStore; @@ -375,6 +376,39 @@ interface ICapabilities { "m.room_versions"?: IRoomVersionsCapability; } +/* eslint-disable camelcase */ +export interface ICrossSigningKey { + keys: { [algorithm: string]: string }; + signatures?: ISignatures; + usage: string[]; + user_id: string; +} + +enum CrossSigningKeyType { + MasterKey = "master_key", + SelfSigningKey = "self_signing_key", + UserSigningKey = "user_signing_key", +} + +export type CrossSigningKeys = Record; + +export interface ISignedKey { + keys: Record; + signatures: ISignatures; + user_id: string; + algorithms: string[]; + device_id: string; +} +/* eslint-enable camelcase */ + +export type KeySignatures = Record>; +interface IUploadKeySignaturesResponse { + failures: Record>; +} + /** * Represents a Matrix Client. Only directly construct this if you want to use * custom modules. Normally, {@link createClient} should be used @@ -2086,7 +2120,7 @@ export class MatrixClient extends EventEmitter { * Get information about the current key backup. * @returns {Promise} Information object from API or null */ - public getKeyBackupVersion(): Promise { + public getKeyBackupVersion(): Promise { return this.http.authedRequest( undefined, "GET", "/room_keys/version", undefined, undefined, { prefix: PREFIX_UNSTABLE }, @@ -2120,7 +2154,7 @@ export class MatrixClient extends EventEmitter { * ] * } */ - public isKeyBackupTrusted(info: IKeyBackupVersion): Promise { + public isKeyBackupTrusted(info: IKeyBackupInfo): Promise { return this.crypto.backupManager.isKeyBackupTrusted(info); } @@ -2143,7 +2177,7 @@ export class MatrixClient extends EventEmitter { * @param {object} info Backup information object as returned by getKeyBackupVersion * @returns {Promise} Resolves when complete. */ - public enableKeyBackup(info: IKeyBackupVersion): Promise { + public enableKeyBackup(info: IKeyBackupInfo): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2219,7 +2253,7 @@ export class MatrixClient extends EventEmitter { * @returns {Promise} Object with 'version' param indicating the version created */ // TODO: Fix types - public async createKeyBackupVersion(info: IKeyBackupVersion): Promise { + public async createKeyBackupVersion(info: IKeyBackupInfo): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2375,7 +2409,7 @@ export class MatrixClient extends EventEmitter { * @param {object} backupInfo Backup metadata from `checkKeyBackup` * @return {Promise} key backup key */ - public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupVersion): Promise { + public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupInfo): Promise { return keyFromAuthData(backupInfo.auth_data, password); } @@ -2388,7 +2422,7 @@ export class MatrixClient extends EventEmitter { * @param {string} recoveryKey The recovery key * @return {Uint8Array} key backup key */ - public keyBackupKeyFromRecoveryKey(recoveryKey: string): ArrayLike { + public keyBackupKeyFromRecoveryKey(recoveryKey: string): Uint8Array { return decodeRecoveryKey(recoveryKey); } @@ -2410,7 +2444,7 @@ export class MatrixClient extends EventEmitter { password: string, targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts: IKeyBackupRestoreOpts, ): Promise { const privKey = await keyFromAuthData(backupInfo.auth_data, password); @@ -2434,7 +2468,7 @@ export class MatrixClient extends EventEmitter { */ // TODO: Types public async restoreKeyBackupWithSecretStorage( - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, targetRoomId?: string, targetSessionId?: string, opts?: IKeyBackupRestoreOpts, @@ -2474,7 +2508,7 @@ export class MatrixClient extends EventEmitter { recoveryKey: string, targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts: IKeyBackupRestoreOpts, ): Promise { const privKey = decodeRecoveryKey(recoveryKey); @@ -2485,7 +2519,7 @@ export class MatrixClient extends EventEmitter { public async restoreKeyBackupWithCache( targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts?: IKeyBackupRestoreOpts, ): Promise { const privKey = await this.crypto.getSessionBackupPrivateKey(); @@ -2499,7 +2533,7 @@ export class MatrixClient extends EventEmitter { privKey: ArrayLike, targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts?: IKeyBackupRestoreOpts, ): Promise { const cacheCompleteCallback = opts?.cacheCompleteCallback; @@ -7090,7 +7124,7 @@ export class MatrixClient extends EventEmitter { return this.http.authedRequest(callback, "POST", "/keys/upload", undefined, content); } - public uploadKeySignatures(content: any): Promise { // TODO: Types + public uploadKeySignatures(content: KeySignatures): Promise { return this.http.authedRequest( undefined, "POST", '/keys/signatures/upload', undefined, content, { @@ -7189,7 +7223,7 @@ export class MatrixClient extends EventEmitter { return this.http.authedRequest(undefined, "GET", path, qps, undefined); } - public uploadDeviceSigningKeys(auth: any, keys: any): Promise { // TODO: Lots of types + public uploadDeviceSigningKeys(auth: any, keys: CrossSigningKeys): Promise<{}> { // TODO: types const data = Object.assign({}, keys); if (auth) Object.assign(data, { auth }); return this.http.authedRequest( @@ -7601,7 +7635,11 @@ export class MatrixClient extends EventEmitter { * supplied. * @return {Promise} Resolves to the result object */ - public sendToDevice(eventType: string, contentMap: any, txnId?: string): Promise { // TODO: Types + public sendToDevice( + eventType: string, + contentMap: { [userId: string]: { [deviceId: string]: Record; } }, + txnId?: string, + ): Promise<{}> { const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", { $eventType: eventType, $txnId: txnId ? txnId : this.makeTxnId(), diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index 7ce177922..720a3f5bd 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -28,26 +28,27 @@ import { decryptAES, encryptAES } from './aes'; import { PkSigning } from "@matrix-org/olm"; import { DeviceInfo } from "./deviceinfo"; import { SecretStorage } from "./SecretStorage"; -import { CryptoStore, MatrixClient } from "../client"; +import { CryptoStore, ICrossSigningKey, ISignedKey, MatrixClient } from "../client"; import { OlmDevice } from "./OlmDevice"; import { ICryptoCallbacks } from "../matrix"; +import { ISignatures } from "../@types/signed"; const KEY_REQUEST_TIMEOUT_MS = 1000 * 60; -function publicKeyFromKeyInfo(keyInfo: any): any { // TODO types +function publicKeyFromKeyInfo(keyInfo: ICrossSigningKey): string { // `keys` is an object with { [`ed25519:${pubKey}`]: pubKey } // We assume only a single key, and we want the bare form without type // prefix, so we select the values. return Object.values(keyInfo.keys)[0]; } -interface ICacheCallbacks { +export interface ICacheCallbacks { getCrossSigningKeyCache?(type: string, expectedPublicKey?: string): Promise; storeCrossSigningKeyCache?(type: string, key: Uint8Array): Promise; } export class CrossSigningInfo extends EventEmitter { - public keys: Record = {}; // TODO types + public keys: Record = {}; public firstUse = true; // This tracks whether we've ever verified this user with any identity. // When you verify a user, any devices online at the time that receive @@ -368,8 +369,8 @@ export class CrossSigningInfo extends EventEmitter { this.keys = {}; } - public setKeys(keys: Record): void { - const signingKeys: Record = {}; + public setKeys(keys: Record): void { + const signingKeys: Record = {}; if (keys.master) { if (keys.master.user_id !== this.userId) { const error = "Mismatched user ID " + keys.master.user_id + @@ -448,7 +449,7 @@ export class CrossSigningInfo extends EventEmitter { } } - public async signObject(data: T, type: string): Promise { + public async signObject(data: T, type: string): Promise { if (!this.keys[type]) { throw new Error( "Attempted to sign with " + type + " key but no such key present", @@ -457,13 +458,13 @@ export class CrossSigningInfo extends EventEmitter { const [pubkey, signing] = await this.getCrossSigningKey(type); try { pkSign(data, signing, this.userId, pubkey); - return data; + return data as T & { signatures: ISignatures }; } finally { signing.free(); } } - public async signUser(key: CrossSigningInfo): Promise { + public async signUser(key: CrossSigningInfo): Promise { if (!this.keys.user_signing) { logger.info("No user signing key: not signing user"); return; @@ -471,7 +472,7 @@ export class CrossSigningInfo extends EventEmitter { return this.signObject(key.keys.master, "user_signing"); } - public async signDevice(userId: string, device: DeviceInfo): Promise { + public async signDevice(userId: string, device: DeviceInfo): Promise { if (userId !== this.userId) { throw new Error( `Trying to sign ${userId}'s device; can only sign our own device`, @@ -481,7 +482,7 @@ export class CrossSigningInfo extends EventEmitter { logger.info("No self signing key: not signing device"); return; } - return this.signObject( + return this.signObject>( { algorithms: device.algorithms, keys: device.keys, diff --git a/src/crypto/EncryptionSetup.js b/src/crypto/EncryptionSetup.ts similarity index 61% rename from src/crypto/EncryptionSetup.js rename to src/crypto/EncryptionSetup.ts index 1a7fcf36a..2afcc15de 100644 --- a/src/crypto/EncryptionSetup.js +++ b/src/crypto/EncryptionSetup.ts @@ -1,11 +1,24 @@ import { logger } from "../logger"; import { MatrixEvent } from "../models/event"; import { EventEmitter } from "events"; -import { createCryptoStoreCacheCallbacks } from "./CrossSigning"; +import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning"; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { PREFIX_UNSTABLE } from "../http-api"; +import { Crypto } from "./index"; import { - PREFIX_UNSTABLE, -} from "../http-api"; + CrossSigningKeys, + ICrossSigningKey, + ICryptoCallbacks, + ISecretStorageKeyInfo, + ISignedKey, + KeySignatures, +} from "../matrix"; +import { IKeyBackupInfo } from "./keybackup"; + +interface ICrossSigningKeys { + authUpload(authData: any): Promise<{}>; + keys: Record; +} /** * Builds an EncryptionSetupOperation by calling any of the add.. methods. @@ -17,18 +30,23 @@ import { * more than once. */ export class EncryptionSetupBuilder { + public readonly accountDataClientAdapter: AccountDataClientAdapter; + public readonly crossSigningCallbacks: CrossSigningCallbacks; + public readonly ssssCryptoCallbacks: SSSSCryptoCallbacks; + + private crossSigningKeys: ICrossSigningKeys = null; + private keySignatures: KeySignatures = null; + private keyBackupInfo: IKeyBackupInfo = null; + private sessionBackupPrivateKey: Uint8Array; + /** * @param {Object.} accountData pre-existing account data, will only be read, not written. * @param {CryptoCallbacks} delegateCryptoCallbacks crypto callbacks to delegate to if the key isn't in cache yet */ - constructor(accountData, delegateCryptoCallbacks) { + constructor(accountData: Record, delegateCryptoCallbacks: ICryptoCallbacks) { this.accountDataClientAdapter = new AccountDataClientAdapter(accountData); this.crossSigningCallbacks = new CrossSigningCallbacks(); this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks); - - this._crossSigningKeys = null; - this._keySignatures = null; - this._keyBackupInfo = null; } /** @@ -42,8 +60,8 @@ export class EncryptionSetupBuilder { * an empty authDict, to obtain the flows. * @param {Object} keys the new keys */ - addCrossSigningKeys(authUpload, keys) { - this._crossSigningKeys = { authUpload, keys }; + public addCrossSigningKeys(authUpload: ICrossSigningKeys["authUpload"], keys: ICrossSigningKeys["keys"]): void { + this.crossSigningKeys = { authUpload, keys }; } /** @@ -54,8 +72,8 @@ export class EncryptionSetupBuilder { * * @param {Object} keyBackupInfo as received from/sent to the server */ - addSessionBackup(keyBackupInfo) { - this._keyBackupInfo = keyBackupInfo; + public addSessionBackup(keyBackupInfo: IKeyBackupInfo): void { + this.keyBackupInfo = keyBackupInfo; } /** @@ -65,8 +83,8 @@ export class EncryptionSetupBuilder { * * @param {Uint8Array} privateKey */ - addSessionBackupPrivateKeyToCache(privateKey) { - this._sessionBackupPrivateKey = privateKey; + public addSessionBackupPrivateKeyToCache(privateKey: Uint8Array): void { + this.sessionBackupPrivateKey = privateKey; } /** @@ -75,14 +93,14 @@ export class EncryptionSetupBuilder { * * @param {String} userId * @param {String} deviceId - * @param {String} signature + * @param {Object} signature */ - addKeySignature(userId, deviceId, signature) { - if (!this._keySignatures) { - this._keySignatures = {}; + public addKeySignature(userId: string, deviceId: string, signature: ISignedKey): void { + if (!this.keySignatures) { + this.keySignatures = {}; } - const userSignatures = this._keySignatures[userId] || {}; - this._keySignatures[userId] = userSignatures; + const userSignatures = this.keySignatures[userId] || {}; + this.keySignatures[userId] = userSignatures; userSignatures[deviceId] = signature; } @@ -91,7 +109,7 @@ export class EncryptionSetupBuilder { * @param {Object} content * @return {Promise} */ - setAccountData(type, content) { + public setAccountData(type: string, content: object): Promise { return this.accountDataClientAdapter.setAccountData(type, content); } @@ -99,13 +117,13 @@ export class EncryptionSetupBuilder { * builds the operation containing all the parts that have been added to the builder * @return {EncryptionSetupOperation} */ - buildOperation() { - const accountData = this.accountDataClientAdapter._values; + public buildOperation(): EncryptionSetupOperation { + const accountData = this.accountDataClientAdapter.values; return new EncryptionSetupOperation( accountData, - this._crossSigningKeys, - this._keyBackupInfo, - this._keySignatures, + this.crossSigningKeys, + this.keyBackupInfo, + this.keySignatures, ); } @@ -118,9 +136,9 @@ export class EncryptionSetupBuilder { * @param {Crypto} crypto * @return {Promise} */ - async persist(crypto) { + public async persist(crypto: Crypto): Promise { // store private keys in cache - if (this._crossSigningKeys) { + if (this.crossSigningKeys) { const cacheCallbacks = createCryptoStoreCacheCallbacks(crypto.cryptoStore, crypto.olmDevice); for (const type of ["master", "self_signing", "user_signing"]) { logger.log(`Cache ${type} cross-signing private key locally`); @@ -132,13 +150,13 @@ export class EncryptionSetupBuilder { 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { crypto.cryptoStore.storeCrossSigningKeys( - txn, this._crossSigningKeys.keys); + txn, this.crossSigningKeys.keys); }, ); } // store session backup key in cache - if (this._sessionBackupPrivateKey) { - await crypto.storeSessionBackupPrivateKey(this._sessionBackupPrivateKey); + if (this.sessionBackupPrivateKey) { + await crypto.storeSessionBackupPrivateKey(this.sessionBackupPrivateKey); } } } @@ -156,58 +174,58 @@ export class EncryptionSetupOperation { * @param {Object} keyBackupInfo * @param {Object} keySignatures */ - constructor(accountData, crossSigningKeys, keyBackupInfo, keySignatures) { - this._accountData = accountData; - this._crossSigningKeys = crossSigningKeys; - this._keyBackupInfo = keyBackupInfo; - this._keySignatures = keySignatures; - } + constructor( + private readonly accountData: Map, + private readonly crossSigningKeys: ICrossSigningKeys, + private readonly keyBackupInfo: IKeyBackupInfo, + private readonly keySignatures: KeySignatures, + ) {} /** * Runs the (remaining part of, in the future) operation by sending requests to the server. - * @param {Crypto} crypto + * @param {Crypto} crypto */ - async apply(crypto) { + public async apply(crypto: Crypto): Promise { const baseApis = crypto.baseApis; // upload cross-signing keys - if (this._crossSigningKeys) { - const keys = {}; - for (const [name, key] of Object.entries(this._crossSigningKeys.keys)) { + if (this.crossSigningKeys) { + const keys: Partial = {}; + for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) { keys[name + "_key"] = key; } // We must only call `uploadDeviceSigningKeys` from inside this auth // helper to ensure we properly handle auth errors. - await this._crossSigningKeys.authUpload(authDict => { - return baseApis.uploadDeviceSigningKeys(authDict, keys); + await this.crossSigningKeys.authUpload(authDict => { + return baseApis.uploadDeviceSigningKeys(authDict, keys as CrossSigningKeys); }); // pass the new keys to the main instance of our own CrossSigningInfo. - crypto.crossSigningInfo.setKeys(this._crossSigningKeys.keys); + crypto.crossSigningInfo.setKeys(this.crossSigningKeys.keys); } // set account data - if (this._accountData) { - for (const [type, content] of this._accountData) { + if (this.accountData) { + for (const [type, content] of this.accountData) { await baseApis.setAccountData(type, content); } } // upload first cross-signing signatures with the new key // (e.g. signing our own device) - if (this._keySignatures) { - await baseApis.uploadKeySignatures(this._keySignatures); + if (this.keySignatures) { + await baseApis.uploadKeySignatures(this.keySignatures); } // need to create/update key backup info - if (this._keyBackupInfo) { - if (this._keyBackupInfo.version) { + if (this.keyBackupInfo) { + if (this.keyBackupInfo.version) { // session backup signature // 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( - undefined, "PUT", "/room_keys/version/" + this._keyBackupInfo.version, + undefined, "PUT", "/room_keys/version/" + this.keyBackupInfo.version, undefined, { - algorithm: this._keyBackupInfo.algorithm, - auth_data: this._keyBackupInfo.auth_data, + algorithm: this.keyBackupInfo.algorithm, + auth_data: this.keyBackupInfo.auth_data, }, { prefix: PREFIX_UNSTABLE }, ); @@ -215,7 +233,7 @@ export class EncryptionSetupOperation { // add new key backup await baseApis.http.authedRequest( undefined, "POST", "/room_keys/version", - undefined, this._keyBackupInfo, + undefined, this.keyBackupInfo, { prefix: PREFIX_UNSTABLE }, ); } @@ -228,20 +246,20 @@ export class EncryptionSetupOperation { * implementing the methods related to account data in MatrixClient */ class AccountDataClientAdapter extends EventEmitter { + public readonly values = new Map(); + /** - * @param {Object.} accountData existing account data + * @param {Object.} existingValues existing account data */ - constructor(accountData) { + constructor(private readonly existingValues: Record) { super(); - this._existingValues = accountData; - this._values = new Map(); } /** * @param {String} type * @return {Promise} the content of the account data */ - getAccountDataFromServer(type) { + public getAccountDataFromServer(type: string): Promise { return Promise.resolve(this.getAccountData(type)); } @@ -249,12 +267,12 @@ class AccountDataClientAdapter extends EventEmitter { * @param {String} type * @return {Object} the content of the account data */ - getAccountData(type) { - const modifiedValue = this._values.get(type); + public getAccountData(type: string): object { + const modifiedValue = this.values.get(type); if (modifiedValue) { return modifiedValue; } - const existingValue = this._existingValues[type]; + const existingValue = this.existingValues[type]; if (existingValue) { return existingValue.getContent(); } @@ -266,9 +284,9 @@ class AccountDataClientAdapter extends EventEmitter { * @param {Object} content * @return {Promise} */ - setAccountData(type, content) { - const lastEvent = this._values.get(type); - this._values.set(type, content); + public setAccountData(type: string, content: object): Promise { + const lastEvent = this.values.get(type); + this.values.set(type, content); // ensure accountData is emitted on the next tick, // as SecretStorage listens for it while calling this method // and it seems to rely on this. @@ -284,27 +302,25 @@ class AccountDataClientAdapter extends EventEmitter { * by both cache callbacks (see createCryptoStoreCacheCallbacks) as non-cache callbacks. * See CrossSigningInfo constructor */ -class CrossSigningCallbacks { - constructor() { - this.privateKeys = new Map(); - } +class CrossSigningCallbacks implements ICryptoCallbacks, ICacheCallbacks { + public readonly privateKeys = new Map(); // cache callbacks - getCrossSigningKeyCache(type, expectedPublicKey) { + public getCrossSigningKeyCache(type: string, expectedPublicKey: string): Promise { return this.getCrossSigningKey(type, expectedPublicKey); } - storeCrossSigningKeyCache(type, key) { + public storeCrossSigningKeyCache(type: string, key: Uint8Array): Promise { this.privateKeys.set(type, key); return Promise.resolve(); } // non-cache callbacks - getCrossSigningKey(type, _expectedPubkey) { + public getCrossSigningKey(type: string, expectedPubkey: string): Promise { return Promise.resolve(this.privateKeys.get(type)); } - saveCrossSigningKeys(privateKeys) { + public saveCrossSigningKeys(privateKeys: Record) { for (const [type, privateKey] of Object.entries(privateKeys)) { this.privateKeys.set(type, privateKey); } @@ -316,39 +332,36 @@ class CrossSigningCallbacks { * the SecretStorage crypto callbacks */ class SSSSCryptoCallbacks { - constructor(delegateCryptoCallbacks) { - this._privateKeys = new Map(); - this._delegateCryptoCallbacks = delegateCryptoCallbacks; - } + private readonly privateKeys = new Map(); - async getSecretStorageKey({ keys }, name) { + constructor(private readonly delegateCryptoCallbacks: ICryptoCallbacks) {} + + public async getSecretStorageKey( + { keys }: { keys: Record }, + name: string, + ): Promise<[string, Uint8Array]> { for (const keyId of Object.keys(keys)) { - const privateKey = this._privateKeys.get(keyId); + const privateKey = this.privateKeys.get(keyId); if (privateKey) { return [keyId, privateKey]; } } // if we don't have the key cached yet, ask // for it to the general crypto callbacks and cache it - if (this._delegateCryptoCallbacks) { - const result = await this._delegateCryptoCallbacks. + if (this.delegateCryptoCallbacks) { + const result = await this.delegateCryptoCallbacks. getSecretStorageKey({ keys }, name); if (result) { const [keyId, privateKey] = result; - this._privateKeys.set(keyId, privateKey); + this.privateKeys.set(keyId, privateKey); } return result; } } - addPrivateKey(keyId, keyInfo, privKey) { - this._privateKeys.set(keyId, privKey); + public addPrivateKey(keyId: string, keyInfo: ISecretStorageKeyInfo, privKey: Uint8Array): void { + this.privateKeys.set(keyId, privKey); // Also pass along to application to cache if it wishes - if ( - this._delegateCryptoCallbacks && - this._delegateCryptoCallbacks.cacheSecretStorageKey - ) { - this._delegateCryptoCallbacks.cacheSecretStorageKey(keyId, keyInfo, privKey); - } + this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey); } } diff --git a/src/crypto/OutgoingRoomKeyRequestManager.js b/src/crypto/OutgoingRoomKeyRequestManager.ts similarity index 54% rename from src/crypto/OutgoingRoomKeyRequestManager.js rename to src/crypto/OutgoingRoomKeyRequestManager.ts index 7f64b5313..07426148a 100644 --- a/src/crypto/OutgoingRoomKeyRequestManager.js +++ b/src/crypto/OutgoingRoomKeyRequestManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd +Copyright 2017 - 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. @@ -15,6 +15,10 @@ limitations under the License. */ import { logger } from '../logger'; +import {CryptoStore, MatrixClient} from "../client"; +import {IRoomKeyRequestBody, IRoomKeyRequestRecipient} from "./index"; +import { OutgoingRoomKeyRequest } from './store/base'; +import {EventType} from "../@types/event"; /** * Internal module. Management of outgoing room key requests. @@ -57,61 +61,58 @@ const SEND_KEY_REQUESTS_DELAY_MS = 500; * * @enum {number} */ -export const ROOM_KEY_REQUEST_STATES = { +export enum RoomKeyRequestState { /** request not yet sent */ - UNSENT: 0, - + Unsent, /** request sent, awaiting reply */ - SENT: 1, - + Sent, /** reply received, cancellation not yet sent */ - CANCELLATION_PENDING: 2, - + CancellationPending, /** * Cancellation not yet sent and will transition to UNSENT instead of * being deleted once the cancellation has been sent. */ - CANCELLATION_PENDING_AND_WILL_RESEND: 3, -}; + CancellationPendingAndWillResend, +} export class OutgoingRoomKeyRequestManager { - constructor(baseApis, deviceId, cryptoStore) { - this._baseApis = baseApis; - this._deviceId = deviceId; - this._cryptoStore = cryptoStore; + // handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null + // if the callback has been set, or if it is still running. + private sendOutgoingRoomKeyRequestsTimer: NodeJS.Timeout = null; - // handle for the delayed call to _sendOutgoingRoomKeyRequests. Non-null - // if the callback has been set, or if it is still running. - this._sendOutgoingRoomKeyRequestsTimer = null; + // sanity check to ensure that we don't end up with two concurrent runs + // of sendOutgoingRoomKeyRequests + private sendOutgoingRoomKeyRequestsRunning = false; - // sanity check to ensure that we don't end up with two concurrent runs - // of _sendOutgoingRoomKeyRequests - this._sendOutgoingRoomKeyRequestsRunning = false; + private clientRunning = false; - this._clientRunning = false; - } + constructor( + private readonly baseApis: MatrixClient, + private readonly deviceId: string, + private readonly cryptoStore: CryptoStore, + ) {} /** * Called when the client is started. Sets background processes running. */ - start() { - this._clientRunning = true; + public start(): void { + this.clientRunning = true; } /** * Called when the client is stopped. Stops any running background processes. */ - stop() { + public stop(): void { logger.log('stopping OutgoingRoomKeyRequestManager'); // stop the timer on the next run - this._clientRunning = false; + this.clientRunning = false; } /** * Send any requests that have been queued */ - sendQueuedRequests() { - this._startTimer(); + public sendQueuedRequests(): void { + this.startTimer(); } /** @@ -131,95 +132,99 @@ export class OutgoingRoomKeyRequestManager { * pending list (or we have established that a similar request already * exists) */ - async queueRoomKeyRequest(requestBody, recipients, resend=false) { - const req = await this._cryptoStore.getOutgoingRoomKeyRequest( + public async queueRoomKeyRequest( + requestBody: IRoomKeyRequestBody, + recipients: IRoomKeyRequestRecipient[], + resend = false, + ): Promise { + const req = await this.cryptoStore.getOutgoingRoomKeyRequest( requestBody, ); if (!req) { - await this._cryptoStore.getOrAddOutgoingRoomKeyRequest({ + await this.cryptoStore.getOrAddOutgoingRoomKeyRequest({ requestBody: requestBody, recipients: recipients, - requestId: this._baseApis.makeTxnId(), - state: ROOM_KEY_REQUEST_STATES.UNSENT, + requestId: this.baseApis.makeTxnId(), + state: RoomKeyRequestState.Unsent, }); } else { switch (req.state) { - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: - case ROOM_KEY_REQUEST_STATES.UNSENT: - // nothing to do here, since we're going to send a request anyways - return; + case RoomKeyRequestState.CancellationPendingAndWillResend: + case RoomKeyRequestState.Unsent: + // nothing to do here, since we're going to send a request anyways + return; - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: { - // existing request is about to be cancelled. If we want to - // resend, then change the state so that it resends after - // cancelling. Otherwise, just cancel the cancellation. - const state = resend ? - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND : - ROOM_KEY_REQUEST_STATES.SENT; - await this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, { - state, - cancellationTxnId: this._baseApis.makeTxnId(), - }, - ); - break; - } - case ROOM_KEY_REQUEST_STATES.SENT: { - // a request has already been sent. If we don't want to - // resend, then do nothing. If we do want to, then cancel the - // existing request and send a new one. - if (resend) { - const state = - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND; - const updatedReq = - await this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.SENT, { - state, - cancellationTxnId: this._baseApis.makeTxnId(), - // need to use a new transaction ID so that - // the request gets sent - requestTxnId: this._baseApis.makeTxnId(), - }, - ); - if (!updatedReq) { - // updateOutgoingRoomKeyRequest couldn't find the request - // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have - // raced with another tab to mark the request cancelled. - // Try again, to make sure the request is resent. - return await this.queueRoomKeyRequest( - requestBody, recipients, resend, - ); - } - - // We don't want to wait for the timer, so we send it - // immediately. (We might actually end up racing with the timer, - // but that's ok: even if we make the request twice, we'll do it - // with the same transaction_id, so only one message will get - // sent). - // - // (We also don't want to wait for the response from the server - // here, as it will slow down processing of received keys if we - // do.) - try { - await this._sendOutgoingRoomKeyRequestCancellation( - updatedReq, - true, - ); - } catch (e) { - logger.error( - "Error sending room key request cancellation;" - + " will retry later.", e, - ); - } - // The request has transitioned from - // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We - // still need to resend the request which is now UNSENT, so - // start the timer if it isn't already started. + case RoomKeyRequestState.CancellationPending: { + // existing request is about to be cancelled. If we want to + // resend, then change the state so that it resends after + // cancelling. Otherwise, just cancel the cancellation. + const state = resend ? + RoomKeyRequestState.CancellationPendingAndWillResend : + RoomKeyRequestState.Sent; + await this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.CancellationPending, { + state, + cancellationTxnId: this.baseApis.makeTxnId(), + }, + ); + break; } - break; - } - default: - throw new Error('unhandled state: ' + req.state); + case RoomKeyRequestState.Sent: { + // a request has already been sent. If we don't want to + // resend, then do nothing. If we do want to, then cancel the + // existing request and send a new one. + if (resend) { + const state = + RoomKeyRequestState.CancellationPendingAndWillResend; + const updatedReq = + await this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Sent, { + state, + cancellationTxnId: this.baseApis.makeTxnId(), + // need to use a new transaction ID so that + // the request gets sent + requestTxnId: this.baseApis.makeTxnId(), + }, + ); + if (!updatedReq) { + // updateOutgoingRoomKeyRequest couldn't find the request + // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have + // raced with another tab to mark the request cancelled. + // Try again, to make sure the request is resent. + return await this.queueRoomKeyRequest( + requestBody, recipients, resend, + ); + } + + // We don't want to wait for the timer, so we send it + // immediately. (We might actually end up racing with the timer, + // but that's ok: even if we make the request twice, we'll do it + // with the same transaction_id, so only one message will get + // sent). + // + // (We also don't want to wait for the response from the server + // here, as it will slow down processing of received keys if we + // do.) + try { + await this.sendOutgoingRoomKeyRequestCancellation( + updatedReq, + true, + ); + } catch (e) { + logger.error( + "Error sending room key request cancellation;" + + " will retry later.", e, + ); + } + // The request has transitioned from + // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We + // still need to resend the request which is now UNSENT, so + // start the timer if it isn't already started. + } + break; + } + default: + throw new Error('unhandled state: ' + req.state); } } } @@ -232,8 +237,8 @@ export class OutgoingRoomKeyRequestManager { * @returns {Promise} resolves when the request has been updated in our * pending list. */ - cancelRoomKeyRequest(requestBody) { - return this._cryptoStore.getOutgoingRoomKeyRequest( + public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { + return this.cryptoStore.getOutgoingRoomKeyRequest( requestBody, ).then((req) => { if (!req) { @@ -241,12 +246,12 @@ export class OutgoingRoomKeyRequestManager { return; } switch (req.state) { - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: + case RoomKeyRequestState.CancellationPending: + case RoomKeyRequestState.CancellationPendingAndWillResend: // nothing to do here return; - case ROOM_KEY_REQUEST_STATES.UNSENT: + case RoomKeyRequestState.Unsent: // just delete it // FIXME: ghahah we may have attempted to send it, and @@ -258,16 +263,16 @@ export class OutgoingRoomKeyRequestManager { 'deleting unnecessary room key request for ' + stringifyRequestBody(requestBody), ); - return this._cryptoStore.deleteOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT, + return this.cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Unsent, ); - case ROOM_KEY_REQUEST_STATES.SENT: { + case RoomKeyRequestState.Sent: { // send a cancellation. - return this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.SENT, { - state: ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, - cancellationTxnId: this._baseApis.makeTxnId(), + return this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Sent, { + state: RoomKeyRequestState.CancellationPending, + cancellationTxnId: this.baseApis.makeTxnId(), }, ).then((updatedReq) => { if (!updatedReq) { @@ -294,14 +299,14 @@ export class OutgoingRoomKeyRequestManager { // (We also don't want to wait for the response from the server // here, as it will slow down processing of received keys if we // do.) - this._sendOutgoingRoomKeyRequestCancellation( + this.sendOutgoingRoomKeyRequestCancellation( updatedReq, ).catch((e) => { logger.error( "Error sending room key request cancellation;" + " will retry later.", e, ); - this._startTimer(); + this.startTimer(); }); }); } @@ -320,10 +325,8 @@ export class OutgoingRoomKeyRequestManager { * @return {Promise} resolves to a list of all the * {@link module:crypto/store/base~OutgoingRoomKeyRequest} */ - getOutgoingSentRoomKeyRequest(userId, deviceId) { - return this._cryptoStore.getOutgoingRoomKeyRequestsByTarget( - userId, deviceId, [ROOM_KEY_REQUEST_STATES.SENT], - ); + public getOutgoingSentRoomKeyRequest(userId: string, deviceId: string): OutgoingRoomKeyRequest[] { + return this.cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [RoomKeyRequestState.Sent]); } /** @@ -333,29 +336,27 @@ export class OutgoingRoomKeyRequestManager { * For example, after initialization or self-verification. * @return {Promise} An array of `queueRoomKeyRequest` outputs. */ - async cancelAndResendAllOutgoingRequests() { - const outgoings = await this._cryptoStore.getAllOutgoingRoomKeyRequestsByState( - ROOM_KEY_REQUEST_STATES.SENT, - ); + public async cancelAndResendAllOutgoingRequests(): Promise { + const outgoings = await this.cryptoStore.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); return Promise.all(outgoings.map(({ requestBody, recipients }) => this.queueRoomKeyRequest(requestBody, recipients, true))); } // start the background timer to send queued requests, if the timer isn't // already running - _startTimer() { - if (this._sendOutgoingRoomKeyRequestsTimer) { + private startTimer(): void { + if (this.sendOutgoingRoomKeyRequestsTimer) { return; } const startSendingOutgoingRoomKeyRequests = () => { - if (this._sendOutgoingRoomKeyRequestsRunning) { + if (this.sendOutgoingRoomKeyRequestsRunning) { throw new Error("RoomKeyRequestSend already in progress!"); } - this._sendOutgoingRoomKeyRequestsRunning = true; + this.sendOutgoingRoomKeyRequestsRunning = true; - this._sendOutgoingRoomKeyRequests().finally(() => { - this._sendOutgoingRoomKeyRequestsRunning = false; + this.sendOutgoingRoomKeyRequests().finally(() => { + this.sendOutgoingRoomKeyRequestsRunning = false; }).catch((e) => { // this should only happen if there is an indexeddb error, // in which case we're a bit stuffed anyway. @@ -365,7 +366,7 @@ export class OutgoingRoomKeyRequestManager { }); }; - this._sendOutgoingRoomKeyRequestsTimer = global.setTimeout( + this.sendOutgoingRoomKeyRequestsTimer = global.setTimeout( startSendingOutgoingRoomKeyRequests, SEND_KEY_REQUESTS_DELAY_MS, ); @@ -374,47 +375,47 @@ export class OutgoingRoomKeyRequestManager { // look for and send any queued requests. Runs itself recursively until // there are no more requests, or there is an error (in which case, the // timer will be restarted before the promise resolves). - _sendOutgoingRoomKeyRequests() { - if (!this._clientRunning) { - this._sendOutgoingRoomKeyRequestsTimer = null; + private sendOutgoingRoomKeyRequests(): Promise { + if (!this.clientRunning) { + this.sendOutgoingRoomKeyRequestsTimer = null; return Promise.resolve(); } - return this._cryptoStore.getOutgoingRoomKeyRequestByState([ - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND, - ROOM_KEY_REQUEST_STATES.UNSENT, - ]).then((req) => { + return this.cryptoStore.getOutgoingRoomKeyRequestByState([ + RoomKeyRequestState.CancellationPending, + RoomKeyRequestState.CancellationPendingAndWillResend, + RoomKeyRequestState.Unsent, + ]).then((req: OutgoingRoomKeyRequest) => { if (!req) { - this._sendOutgoingRoomKeyRequestsTimer = null; + this.sendOutgoingRoomKeyRequestsTimer = null; return; } let prom; switch (req.state) { - case ROOM_KEY_REQUEST_STATES.UNSENT: - prom = this._sendOutgoingRoomKeyRequest(req); + case RoomKeyRequestState.Unsent: + prom = this.sendOutgoingRoomKeyRequest(req); break; - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: - prom = this._sendOutgoingRoomKeyRequestCancellation(req); + case RoomKeyRequestState.CancellationPending: + prom = this.sendOutgoingRoomKeyRequestCancellation(req); break; - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: - prom = this._sendOutgoingRoomKeyRequestCancellation(req, true); + case RoomKeyRequestState.CancellationPendingAndWillResend: + prom = this.sendOutgoingRoomKeyRequestCancellation(req, true); break; } return prom.then(() => { // go around the loop again - return this._sendOutgoingRoomKeyRequests(); + return this.sendOutgoingRoomKeyRequests(); }).catch((e) => { logger.error("Error sending room key request; will retry later.", e); - this._sendOutgoingRoomKeyRequestsTimer = null; + this.sendOutgoingRoomKeyRequestsTimer = null; }); }); } // given a RoomKeyRequest, send it and update the request record - _sendOutgoingRoomKeyRequest(req) { + private sendOutgoingRoomKeyRequest(req: OutgoingRoomKeyRequest): Promise { logger.log( `Requesting keys for ${stringifyRequestBody(req.requestBody)}` + ` from ${stringifyRecipientList(req.recipients)}` + @@ -423,24 +424,24 @@ export class OutgoingRoomKeyRequestManager { const requestMessage = { action: "request", - requesting_device_id: this._deviceId, + requesting_device_id: this.deviceId, request_id: req.requestId, body: req.requestBody, }; - return this._sendMessageToDevices( + return this.sendMessageToDevices( requestMessage, req.recipients, req.requestTxnId || req.requestId, ).then(() => { - return this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT, - { state: ROOM_KEY_REQUEST_STATES.SENT }, + return this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Unsent, + { state: RoomKeyRequestState.Sent }, ); }); } // Given a RoomKeyRequest, cancel it and delete the request record unless // andResend is set, in which case transition to UNSENT. - _sendOutgoingRoomKeyRequestCancellation(req, andResend) { + private sendOutgoingRoomKeyRequestCancellation(req: OutgoingRoomKeyRequest, andResend = false): Promise { logger.log( `Sending cancellation for key request for ` + `${stringifyRequestBody(req.requestBody)} to ` + @@ -450,30 +451,30 @@ export class OutgoingRoomKeyRequestManager { const requestMessage = { action: "request_cancellation", - requesting_device_id: this._deviceId, + requesting_device_id: this.deviceId, request_id: req.requestId, }; - return this._sendMessageToDevices( + return this.sendMessageToDevices( requestMessage, req.recipients, req.cancellationTxnId, ).then(() => { if (andResend) { // We want to resend, so transition to UNSENT - return this._cryptoStore.updateOutgoingRoomKeyRequest( + return this.cryptoStore.updateOutgoingRoomKeyRequest( req.requestId, - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND, - { state: ROOM_KEY_REQUEST_STATES.UNSENT }, + RoomKeyRequestState.CancellationPendingAndWillResend, + { state: RoomKeyRequestState.Unsent }, ); } - return this._cryptoStore.deleteOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, + return this.cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.CancellationPending, ); }); } // send a RoomKeyRequest to a list of recipients - _sendMessageToDevices(message, recipients, txnId) { - const contentMap = {}; + private sendMessageToDevices(message, recipients, txnId: string): Promise<{}> { + const contentMap: Record>> = {}; for (const recip of recipients) { if (!contentMap[recip.userId]) { contentMap[recip.userId] = {}; @@ -481,9 +482,7 @@ export class OutgoingRoomKeyRequestManager { contentMap[recip.userId][recip.deviceId] = message; } - return this._baseApis.sendToDevice( - 'm.room_key_request', contentMap, txnId, - ); + return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId); } } diff --git a/src/crypto/aes.ts b/src/crypto/aes.ts index 3cc465419..1c370ecc4 100644 --- a/src/crypto/aes.ts +++ b/src/crypto/aes.ts @@ -25,7 +25,7 @@ const subtleCrypto = (typeof window !== "undefined" && window.crypto) ? // salt for HKDF, with 8 bytes of zeros const zeroSalt = new Uint8Array(8); -interface IEncryptedPayload { +export interface IEncryptedPayload { iv: string; ciphertext: string; mac: string; diff --git a/src/crypto/api.ts b/src/crypto/api.ts index 39469a83a..8daafb6d7 100644 --- a/src/crypto/api.ts +++ b/src/crypto/api.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { DeviceInfo } from "./deviceinfo"; -import { IKeyBackupVersion } from "./keybackup"; +import { IKeyBackupInfo } from "./keybackup"; import { ISecretStorageKeyInfo } from "../matrix"; // TODO: Merge this with crypto.js once converted @@ -85,7 +85,7 @@ export interface ICreateSecretStorageOpts { * The current key backup object. If passed, * the passphrase and recovery key from this backup will be used. */ - keyBackupInfo?: IKeyBackupVersion; + keyBackupInfo?: IKeyBackupInfo; /** * If true, a new key backup version will be diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 3d5485f27..3a8422a74 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -29,11 +29,11 @@ import { keyFromPassphrase } from './key_passphrase'; import { sleep } from "../utils"; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; import { encodeRecoveryKey } from './recoverykey'; -import { IKeyBackupVersion } from "./keybackup"; +import { IKeyBackupInfo } from "./keybackup"; const KEY_BACKUP_KEYS_PER_REQUEST = 200; -type AuthData = IKeyBackupVersion["auth_data"]; +type AuthData = IKeyBackupInfo["auth_data"]; type SigInfo = { deviceId: string, @@ -49,7 +49,7 @@ export type TrustInfo = { }; export interface IKeyBackupCheck { - backupInfo: IKeyBackupVersion; + backupInfo: IKeyBackupInfo; trustInfo: TrustInfo; } @@ -90,7 +90,7 @@ interface BackupAlgorithm { */ export class BackupManager { private algorithm: BackupAlgorithm | undefined; - public backupInfo: IKeyBackupVersion | undefined; // The info dict from /room_keys/version + public backupInfo: IKeyBackupInfo | 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) { @@ -102,7 +102,7 @@ export class BackupManager { return this.backupInfo && this.backupInfo.version; } - public static async makeAlgorithm(info: IKeyBackupVersion, getKey: GetKey): Promise { + public static async makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise { const Algorithm = algorithmsByName[info.algorithm]; if (!Algorithm) { throw new Error("Unknown backup algorithm"); @@ -110,7 +110,7 @@ export class BackupManager { return await Algorithm.init(info.auth_data, getKey); } - public async enableKeyBackup(info: IKeyBackupVersion): Promise { + public async enableKeyBackup(info: IKeyBackupInfo): Promise { this.backupInfo = info; if (this.algorithm) { this.algorithm.free(); @@ -166,7 +166,7 @@ export class BackupManager { }; } - public async createKeyBackupVersion(info: IKeyBackupVersion): Promise { + public async createKeyBackupVersion(info: IKeyBackupInfo): Promise { this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); } @@ -183,7 +183,7 @@ export class BackupManager { this.checkedForBackup = true; return null; } - let backupInfo: IKeyBackupVersion; + let backupInfo: IKeyBackupInfo; try { backupInfo = await this.baseApis.getKeyBackupVersion(); } catch (e) { @@ -260,7 +260,7 @@ export class BackupManager { * ] * } */ - public async isKeyBackupTrusted(backupInfo: IKeyBackupVersion): Promise { + public async isKeyBackupTrusted(backupInfo: IKeyBackupInfo): Promise { const ret = { usable: false, trusted_locally: false, diff --git a/src/crypto/deviceinfo.ts b/src/crypto/deviceinfo.ts index d723eac4b..3272e047e 100644 --- a/src/crypto/deviceinfo.ts +++ b/src/crypto/deviceinfo.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { ISignatures } from "../@types/signed"; + /** * @module crypto/deviceinfo */ diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 5752f9df6..caacc4dae 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -52,10 +52,11 @@ import { IStore } from "../store"; import { Room } from "../models/room"; import { RoomMember } from "../models/room-member"; import { MatrixEvent } from "../models/event"; -import { MatrixClient, IKeysUploadResponse, SessionStore, CryptoStore } from "../client"; +import { MatrixClient, IKeysUploadResponse, SessionStore, CryptoStore, ISignedKey } from "../client"; import type { EncryptionAlgorithm, DecryptionAlgorithm } from "./algorithms/base"; import type { RoomList } from "./RoomList"; import { IRecoveryKey, IEncryptedEventInfo } from "./api"; +import { IKeyBackupInfo } from "./keybackup"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -91,7 +92,7 @@ interface IInitOpts { export interface IBootstrapCrossSigningOpts { setupNewCrossSigning?: boolean; - authUploadDeviceSigningKeys?(makeRequest: (authData: any) => void): Promise; + authUploadDeviceSigningKeys?(makeRequest: (authData: any) => void): Promise<{}>; } interface IBootstrapSecretStorageOpts { @@ -111,7 +112,7 @@ interface IRoomKey { algorithm: string; } -interface IRoomKeyRequestBody extends IRoomKey { +export interface IRoomKeyRequestBody extends IRoomKey { session_id: string; sender_key: string } @@ -157,7 +158,7 @@ interface ISyncDeviceLists { left: string[]; } -interface IRoomKeyRequestRecipient { +export interface IRoomKeyRequestRecipient { userId: string; deviceId: string; } @@ -276,7 +277,7 @@ export class Crypto extends EventEmitter { * or a class that implements a verification method. */ constructor( - private readonly baseApis: MatrixClient, + public readonly baseApis: MatrixClient, public readonly sessionStore: SessionStore, private readonly userId: string, private readonly deviceId: string, @@ -622,7 +623,7 @@ export class Crypto extends EventEmitter { // Cross-sign own device const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); - const deviceSignature = await crossSigningInfo.signDevice(this.userId, device); + const deviceSignature = await crossSigningInfo.signDevice(this.userId, device) as ISignedKey; builder.addKeySignature(this.userId, this.deviceId, deviceSignature); // Sign message key backup with cross-signing master key @@ -932,7 +933,7 @@ export class Crypto extends EventEmitter { await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey)); // create keyBackupInfo object to add to builder - const data = { + const data: IKeyBackupInfo = { algorithm: info.algorithm, auth_data: info.auth_data, }; @@ -2843,8 +2844,8 @@ export class Crypto extends EventEmitter { * Re-send any outgoing key requests, eg after verification * @returns {Promise} */ - public cancelAndResendAllOutgoingKeyRequests(): Promise { - return this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); + public async cancelAndResendAllOutgoingKeyRequests(): Promise { + await this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); } /** @@ -3291,9 +3292,7 @@ export class Crypto extends EventEmitter { // it. This won't always be the case though so we need to re-send any that have already been sent // to avoid races. const requestsToResend = - await this.outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest( - sender, device.deviceId, - ); + await this.outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest(sender, device.deviceId); for (const keyReq of requestsToResend) { this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true); } diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index 20ca4f146..123f18f76 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -32,7 +32,7 @@ export interface IKeyBackupRoomSessions { } /* eslint-disable camelcase */ -export interface IKeyBackupVersion { +export interface IKeyBackupInfo { algorithm: string; auth_data: { public_key: string; @@ -41,9 +41,9 @@ export interface IKeyBackupVersion { private_key_iterations: number; private_key_bits?: number; }; - count: number; - etag: string; - version: string; // number contained within + count?: number; + etag?: string; + version?: string; // number contained within } /* eslint-enable camelcase */ diff --git a/src/crypto/olmlib.ts b/src/crypto/olmlib.ts index 646f330ec..f7ed067ee 100644 --- a/src/crypto/olmlib.ts +++ b/src/crypto/olmlib.ts @@ -566,7 +566,7 @@ export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string { * @param {Uint8Array} uint8Array The data to encode. * @return {string} The unpadded base64. */ -export function encodeUnpaddedBase64(uint8Array: Uint8Array): string { +export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string { return encodeBase64(uint8Array).replace(/=+$/g, ''); } diff --git a/src/crypto/store/base.js b/src/crypto/store/base.ts similarity index 72% rename from src/crypto/store/base.js rename to src/crypto/store/base.ts index d9d1f7a94..d76fb9ead 100644 --- a/src/crypto/store/base.js +++ b/src/crypto/store/base.ts @@ -10,6 +10,9 @@ * @interface CryptoStore */ +import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index"; +import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager"; + /** * Represents an outgoing room key request * @@ -32,3 +35,11 @@ * @property {Number} state current state of this request (states are defined * in {@link module:crypto/OutgoingRoomKeyRequestManager~ROOM_KEY_REQUEST_STATES}) */ +export interface OutgoingRoomKeyRequest { + requestId: string; + requestTxnId?: string; + cancellationTxnId?: string; + recipients: IRoomKeyRequestRecipient[]; + requestBody: IRoomKeyRequestBody; + state: RoomKeyRequestState; +} From 1ca13f4ce970475d2ee816e4bdd4cac31da91451 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 24 Jun 2021 19:28:04 +0100 Subject: [PATCH 100/124] delint --- src/crypto/OutgoingRoomKeyRequestManager.ts | 6 +++--- src/crypto/deviceinfo.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/crypto/OutgoingRoomKeyRequestManager.ts b/src/crypto/OutgoingRoomKeyRequestManager.ts index 07426148a..a684b2c71 100644 --- a/src/crypto/OutgoingRoomKeyRequestManager.ts +++ b/src/crypto/OutgoingRoomKeyRequestManager.ts @@ -15,10 +15,10 @@ limitations under the License. */ import { logger } from '../logger'; -import {CryptoStore, MatrixClient} from "../client"; -import {IRoomKeyRequestBody, IRoomKeyRequestRecipient} from "./index"; +import { CryptoStore, MatrixClient } from "../client"; +import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "./index"; import { OutgoingRoomKeyRequest } from './store/base'; -import {EventType} from "../@types/event"; +import { EventType } from "../@types/event"; /** * Internal module. Management of outgoing room key requests. diff --git a/src/crypto/deviceinfo.ts b/src/crypto/deviceinfo.ts index 3272e047e..870899349 100644 --- a/src/crypto/deviceinfo.ts +++ b/src/crypto/deviceinfo.ts @@ -26,7 +26,7 @@ export interface IDevice { verified: DeviceVerification; known: boolean; unsigned?: Record; - signatures?: Record; + signatures?: ISignatures; } enum DeviceVerification { @@ -92,7 +92,7 @@ export class DeviceInfo { public verified = DeviceVerification.Unverified; public known = false; public unsigned: Record = {}; - public signatures: Record = {}; + public signatures: ISignatures = {}; constructor(public readonly deviceId: string) {} From 3675e95970adfd91bd5b01823e14e63a05a9e239 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 24 Jun 2021 21:22:56 +0100 Subject: [PATCH 101/124] fix the upset CI --- src/crypto/EncryptionSetup.ts | 4 ++-- src/crypto/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/crypto/EncryptionSetup.ts b/src/crypto/EncryptionSetup.ts index 2afcc15de..140c4cadb 100644 --- a/src/crypto/EncryptionSetup.ts +++ b/src/crypto/EncryptionSetup.ts @@ -4,7 +4,7 @@ import { EventEmitter } from "events"; import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning"; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; import { PREFIX_UNSTABLE } from "../http-api"; -import { Crypto } from "./index"; +import { Crypto, IBootstrapCrossSigningOpts } from "./index"; import { CrossSigningKeys, ICrossSigningKey, @@ -16,7 +16,7 @@ import { import { IKeyBackupInfo } from "./keybackup"; interface ICrossSigningKeys { - authUpload(authData: any): Promise<{}>; + authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"]; keys: Record; } diff --git a/src/crypto/index.ts b/src/crypto/index.ts index caacc4dae..9d60276e3 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -92,7 +92,7 @@ interface IInitOpts { export interface IBootstrapCrossSigningOpts { setupNewCrossSigning?: boolean; - authUploadDeviceSigningKeys?(makeRequest: (authData: any) => void): Promise<{}>; + authUploadDeviceSigningKeys?(makeRequest: (authData: any) => {}): Promise; } interface IBootstrapSecretStorageOpts { From 48ad9ba3d71685a4381475bc951cae55cc54f760 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 24 Jun 2021 21:48:55 +0100 Subject: [PATCH 102/124] Some more types --- src/client.ts | 4 +- src/crypto/algorithms/{base.js => base.ts} | 121 ++++++++++++++------- src/crypto/index.ts | 15 ++- 3 files changed, 93 insertions(+), 47 deletions(-) rename src/crypto/algorithms/{base.js => base.ts} (63%) diff --git a/src/client.ts b/src/client.ts index 32874e2e1..b89594ab5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -47,7 +47,7 @@ import { PREFIX_UNSTABLE, retryNetworkOperation, } from "./http-api"; -import { Crypto, fixBackupKey, IBootstrapCrossSigningOpts, isCryptoAvailable } from './crypto'; +import { Crypto, fixBackupKey, IBootstrapCrossSigningOpts, IMegolmSessionData, isCryptoAvailable } from './crypto'; import { DeviceInfo, IDevice } from "./crypto/deviceinfo"; import { decodeRecoveryKey } from './crypto/recoverykey'; import { keyFromAuthData } from './crypto/key_passphrase'; @@ -2096,7 +2096,7 @@ export class MatrixClient extends EventEmitter { * @return {Promise} a promise which resolves when the keys * have been imported */ - public importRoomKeys(keys: any[], opts: IImportRoomKeysOpts): Promise { + public importRoomKeys(keys: IMegolmSessionData[], opts: IImportRoomKeysOpts): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } diff --git a/src/crypto/algorithms/base.js b/src/crypto/algorithms/base.ts similarity index 63% rename from src/crypto/algorithms/base.js rename to src/crypto/algorithms/base.ts index 87b8a82c0..256a499de 100644 --- a/src/crypto/algorithms/base.js +++ b/src/crypto/algorithms/base.ts @@ -1,5 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd +Copyright 2016 - 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. @@ -20,13 +20,20 @@ limitations under the License. * @module */ +import { MatrixClient } from "../../client"; +import { Room } from "../../models/room"; +import { OlmDevice } from "../OlmDevice"; +import { MatrixEvent, RoomMember } from "../.."; +import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; +import { DeviceInfo } from "../deviceinfo"; + /** * map of registered encryption algorithm classes. A map from string to {@link * module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} class * * @type {Object.} */ -export const ENCRYPTION_CLASSES = {}; +export const ENCRYPTION_CLASSES: Record = {}; /** * map of registered encryption algorithm classes. Map from string to {@link @@ -34,7 +41,16 @@ export const ENCRYPTION_CLASSES = {}; * * @type {Object.} */ -export const DECRYPTION_CLASSES = {}; +export const DECRYPTION_CLASSES: Record = {}; + +interface IParams { + userId: string; + deviceId: string; + crypto: Crypto; + olmDevice: OlmDevice; + baseApis: MatrixClient; + roomId: string; +} /** * base type for encryption implementations @@ -50,14 +66,21 @@ export const DECRYPTION_CLASSES = {}; * @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 */ -export class EncryptionAlgorithm { - constructor(params) { - this._userId = params.userId; - this._deviceId = params.deviceId; - this._crypto = params.crypto; - this._olmDevice = params.olmDevice; - this._baseApis = params.baseApis; - this._roomId = params.roomId; +export abstract class EncryptionAlgorithm { + protected readonly userId: string; + protected readonly deviceId: string; + protected readonly crypto: Crypto; + protected readonly olmDevice: OlmDevice; + protected readonly baseApis: MatrixClient; + protected readonly roomId: string; + + constructor(params: IParams) { + this.userId = params.userId; + this.deviceId = params.deviceId; + this.crypto = params.crypto; + this.olmDevice = params.olmDevice; + this.baseApis = params.baseApis; + this.roomId = params.roomId; } /** @@ -66,21 +89,22 @@ export class EncryptionAlgorithm { * * @param {module:models/room} room the room the event is in */ - prepareToEncrypt(room) { - } + public abstract prepareToEncrypt(room: Room): void; /** * Encrypt a message event * * @method module:crypto/algorithms/base.EncryptionAlgorithm.encryptMessage + * @public * @abstract * * @param {module:models/room} room * @param {string} eventType - * @param {object} plaintext event content + * @param {object} content event content * * @return {Promise} Promise which resolves to the new event body */ + public abstract encryptMessage(room: Room, eventType: string, content: object): Promise; /** * Called when the membership of a member of the room changes. @@ -89,9 +113,18 @@ export class EncryptionAlgorithm { * @param {module:models/room-member} member user whose membership changed * @param {string=} oldMembership previous membership * @public + * @abstract */ - onRoomMembership(event, member, oldMembership) { - } + public abstract onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string); + + public abstract reshareKeyWithDevice( + senderKey: string, + sessionId: string, + userId: string, + device: DeviceInfo, + ): Promise; + + public forceDiscardSession?: () => void; } /** @@ -106,13 +139,19 @@ export class EncryptionAlgorithm { * @param {string=} params.roomId The ID of the room we will be receiving * from. Null for to-device events. */ -export class DecryptionAlgorithm { - constructor(params) { - this._userId = params.userId; - this._crypto = params.crypto; - this._olmDevice = params.olmDevice; - this._baseApis = params.baseApis; - this._roomId = params.roomId; +export abstract class DecryptionAlgorithm { + private readonly userId: string; + private readonly crypto: Crypto; + private readonly olmDevice: OlmDevice; + private readonly baseApis: MatrixClient; + private readonly roomId: string; + + constructor(params: Omit) { + this.userId = params.userId; + this.crypto = params.crypto; + this.olmDevice = params.olmDevice; + this.baseApis = params.baseApis; + this.roomId = params.roomId; } /** @@ -127,6 +166,7 @@ export class DecryptionAlgorithm { * resolves once we have finished decrypting. Rejects with an * `algorithms.DecryptionError` if there is a problem decrypting the event. */ + public abstract decryptEvent(event: MatrixEvent): Promise; /** * Handle a key event @@ -135,7 +175,7 @@ export class DecryptionAlgorithm { * * @param {module:models/event.MatrixEvent} params event key event */ - onRoomKeyEvent(params) { + public onRoomKeyEvent(params: MatrixEvent): void { // ignore by default } @@ -143,8 +183,9 @@ export class DecryptionAlgorithm { * Import a room key * * @param {module:crypto/OlmDevice.MegolmSessionData} session + * @param {object} opts object */ - importRoomKey(session) { + public async importRoomKey(session: IMegolmSessionData, opts: object): Promise { // ignore by default } @@ -155,7 +196,7 @@ export class DecryptionAlgorithm { * @return {Promise} true if we have the keys and could (theoretically) share * them; else false. */ - hasKeysForKeyRequest(keyRequest) { + public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { return Promise.resolve(false); } @@ -164,7 +205,7 @@ export class DecryptionAlgorithm { * * @param {module:crypto~IncomingRoomKeyRequest} keyRequest */ - shareKeysWithDevice(keyRequest) { + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest) { throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); } @@ -174,9 +215,12 @@ export class DecryptionAlgorithm { * * @param {string} senderKey the sender's key */ - async retryDecryptionFromSender(senderKey) { + public async retryDecryptionFromSender(senderKey: string): Promise { // ignore by default } + + public onRoomKeyWithheldEvent?: (event: MatrixEvent) => Promise; + public sendSharedHistoryInboundSessions?: (devicesByUser: Record) => Promise; } /** @@ -191,22 +235,21 @@ export class DecryptionAlgorithm { * @extends Error */ export class DecryptionError extends Error { - constructor(code, msg, details) { + public readonly detailedString: string; + + constructor(public readonly code: string, msg: string, details: Record) { super(msg); this.code = code; this.name = 'DecryptionError'; - this.detailedString = _detailedStringForDecryptionError(this, details); + this.detailedString = detailedStringForDecryptionError(this, details); } } -function _detailedStringForDecryptionError(err, details) { +function detailedStringForDecryptionError(err: DecryptionError, details: Record): string { let result = err.name + '[msg: ' + err.message; if (details) { - result += ', ' + - Object.keys(details).map( - (k) => k + ': ' + details[k], - ).join(', '); + result += ', ' + Object.keys(details).map((k) => k + ': ' + details[k]).join(', '); } result += ']'; @@ -224,7 +267,7 @@ function _detailedStringForDecryptionError(err, details) { * @extends Error */ export class UnknownDeviceError extends Error { - constructor(msg, devices) { + constructor(msg: string, public readonly devices: Record>) { super(msg); this.name = "UnknownDeviceError"; this.devices = devices; @@ -244,7 +287,11 @@ export class UnknownDeviceError extends Error { * module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm} * implementation */ -export function registerAlgorithm(algorithm, encryptor, decryptor) { +export function registerAlgorithm( + algorithm: string, + encryptor: EncryptionAlgorithm, + decryptor: DecryptionAlgorithm, +): void { ENCRYPTION_CLASSES[algorithm] = encryptor; DECRYPTION_CLASSES[algorithm] = decryptor; } diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 9d60276e3..132821219 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -117,13 +117,14 @@ export interface IRoomKeyRequestBody extends IRoomKey { sender_key: string } -interface IMegolmSessionData { +export interface IMegolmSessionData { sender_key: string; forwarding_curve25519_key_chain: string[]; sender_claimed_keys: Record; room_id: string; session_id: string; session_key: string; + algorithm: string; } /* eslint-enable camelcase */ @@ -168,7 +169,7 @@ interface ISignableObject { unsigned?: object } -interface IEventDecryptionResult { +export interface IEventDecryptionResult { clearEvent: object; senderCurve25519Key?: string; claimedEd25519Key?: string; @@ -193,7 +194,7 @@ export class Crypto extends EventEmitter { private readonly reEmitter: ReEmitter; private readonly verificationMethods: any; // TODO types - private readonly supportedAlgorithms: DecryptionAlgorithm[]; + private readonly supportedAlgorithms: string[]; private readonly outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager; private readonly toDeviceVerificationRequests: ToDeviceRequests; private readonly inRoomVerificationRequests: InRoomRequests; @@ -2630,7 +2631,7 @@ export class Crypto extends EventEmitter { * @param {Function} opts.progressCallback called with an object which has a stage param * @return {Promise} a promise which resolves once the keys have been imported */ - public importRoomKeys(keys: IRoomKey[], opts: any = {}): Promise { // TODO types + public importRoomKeys(keys: IMegolmSessionData[], opts: any = {}): Promise { // TODO types let successes = 0; let failures = 0; const total = keys.length; @@ -3430,9 +3431,7 @@ export class Crypto extends EventEmitter { } try { - await encryptor.reshareKeyWithDevice( - body.sender_key, body.session_id, userId, device, - ); + await encryptor.reshareKeyWithDevice(body.sender_key, body.session_id, userId, device); } catch (e) { logger.warn( "Failed to re-share keys for session " + body.session_id + @@ -3643,7 +3642,7 @@ export function fixBackupKey(key: string): string | null { * the relevant crypto algorithm implementation to share the keys for * this request. */ -class IncomingRoomKeyRequest { +export class IncomingRoomKeyRequest { public readonly userId: string; public readonly deviceId: string; public readonly requestId: string; From 835aafcb179bccacae8057c6f9afa74d37c80ca2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 25 Jun 2021 10:18:46 +0100 Subject: [PATCH 103/124] Type the rest of algorithms because tests are unhappy otherwise --- src/crypto/DeviceList.ts | 32 +- src/crypto/algorithms/base.ts | 46 +- src/crypto/algorithms/{index.js => index.ts} | 3 +- src/crypto/algorithms/megolm.js | 1788 ----------------- src/crypto/algorithms/megolm.ts | 1833 ++++++++++++++++++ src/crypto/algorithms/olm.js | 361 ---- src/crypto/algorithms/olm.ts | 355 ++++ src/crypto/index.ts | 7 +- src/models/event.ts | 2 +- 9 files changed, 2225 insertions(+), 2202 deletions(-) rename src/crypto/algorithms/{index.js => index.ts} (88%) delete mode 100644 src/crypto/algorithms/megolm.js create mode 100644 src/crypto/algorithms/megolm.ts delete mode 100644 src/crypto/algorithms/olm.js create mode 100644 src/crypto/algorithms/olm.ts diff --git a/src/crypto/DeviceList.ts b/src/crypto/DeviceList.ts index f5ec71d1a..71d3d364d 100644 --- a/src/crypto/DeviceList.ts +++ b/src/crypto/DeviceList.ts @@ -59,7 +59,7 @@ enum TrackingStatus { UpToDate, } -type DeviceInfoMap = Record>; +export type DeviceInfoMap = Record>; /** * @alias module:crypto/DeviceList @@ -70,7 +70,7 @@ export class DeviceList extends EventEmitter { // [device info] // } // } - private devices: DeviceInfoMap = {}; + private devices: Record> = {}; // userId -> { // [key info] @@ -315,7 +315,7 @@ export class DeviceList extends EventEmitter { * @return {Object} userId->deviceId->{@link module:crypto/deviceinfo|DeviceInfo}. */ private getDevicesFromStore(userIds: string[]): DeviceInfoMap { - const stored = {}; + const stored: DeviceInfoMap = {}; userIds.map((u) => { stored[u] = {}; const devices = this.getStoredDevicesForUser(u) || []; @@ -463,27 +463,11 @@ export class DeviceList extends EventEmitter { /** * Replaces the list of devices for a user with the given device list * - * @param {string} u The user ID - * @param {Object} devs New device info for user + * @param {string} userId The user ID + * @param {Object} devices New device info for user */ - public storeDevicesForUser(u: string, devs: Record): void { - // remove previous devices from userByIdentityKey - if (this.devices[u] !== undefined) { - for (const [deviceId, dev] of Object.entries(this.devices[u])) { - const identityKey = dev.keys['curve25519:'+deviceId]; - - delete this.userByIdentityKey[identityKey]; - } - } - - this.devices[u] = devs; - - // add new ones - for (const [deviceId, dev] of Object.entries(devs)) { - const identityKey = dev.keys['curve25519:'+deviceId]; - - this.userByIdentityKey[identityKey] = u; - } + public storeDevicesForUser(userId: string, devices: Record): void { + this.setRawStoredDevicesForUser(userId, devices); this.dirty = true; } @@ -859,7 +843,7 @@ class DeviceListUpdateSerialiser { ); // put the updates into the object that will be returned as our results - const storage = {}; + const storage: Record = {}; Object.keys(userStore).forEach((deviceId) => { storage[deviceId] = userStore[deviceId].toStorage(); }); diff --git a/src/crypto/algorithms/base.ts b/src/crypto/algorithms/base.ts index 256a499de..7f687774b 100644 --- a/src/crypto/algorithms/base.ts +++ b/src/crypto/algorithms/base.ts @@ -24,7 +24,7 @@ import { MatrixClient } from "../../client"; import { Room } from "../../models/room"; import { OlmDevice } from "../OlmDevice"; import { MatrixEvent, RoomMember } from "../.."; -import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; +import { Crypto, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; import { DeviceInfo } from "../deviceinfo"; /** @@ -33,7 +33,9 @@ import { DeviceInfo } from "../deviceinfo"; * * @type {Object.} */ -export const ENCRYPTION_CLASSES: Record = {}; +export const ENCRYPTION_CLASSES: Record EncryptionAlgorithm> = {}; + +type DecryptionClassParams = Omit; /** * map of registered encryption algorithm classes. Map from string to {@link @@ -41,7 +43,7 @@ export const ENCRYPTION_CLASSES: Record = {}; * * @type {Object.} */ -export const DECRYPTION_CLASSES: Record = {}; +export const DECRYPTION_CLASSES: Record DecryptionAlgorithm> = {}; interface IParams { userId: string; @@ -50,6 +52,7 @@ interface IParams { olmDevice: OlmDevice; baseApis: MatrixClient; roomId: string; + config: object; } /** @@ -89,7 +92,7 @@ export abstract class EncryptionAlgorithm { * * @param {module:models/room} room the room the event is in */ - public abstract prepareToEncrypt(room: Room): void; + public prepareToEncrypt(room: Room): void {} /** * Encrypt a message event @@ -115,16 +118,16 @@ export abstract class EncryptionAlgorithm { * @public * @abstract */ - public abstract onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string); + public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {} - public abstract reshareKeyWithDevice( + public reshareKeyWithDevice?( senderKey: string, sessionId: string, userId: string, device: DeviceInfo, ): Promise; - public forceDiscardSession?: () => void; + public forceDiscardSession?(): void; } /** @@ -140,13 +143,13 @@ export abstract class EncryptionAlgorithm { * from. Null for to-device events. */ export abstract class DecryptionAlgorithm { - private readonly userId: string; - private readonly crypto: Crypto; - private readonly olmDevice: OlmDevice; - private readonly baseApis: MatrixClient; - private readonly roomId: string; + protected readonly userId: string; + protected readonly crypto: Crypto; + protected readonly olmDevice: OlmDevice; + protected readonly baseApis: MatrixClient; + protected readonly roomId: string; - constructor(params: Omit) { + constructor(params: DecryptionClassParams) { this.userId = params.userId; this.crypto = params.crypto; this.olmDevice = params.olmDevice; @@ -205,7 +208,7 @@ export abstract class DecryptionAlgorithm { * * @param {module:crypto~IncomingRoomKeyRequest} keyRequest */ - public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest) { + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); } @@ -215,12 +218,13 @@ export abstract class DecryptionAlgorithm { * * @param {string} senderKey the sender's key */ - public async retryDecryptionFromSender(senderKey: string): Promise { + public async retryDecryptionFromSender(senderKey: string): Promise { // ignore by default + return false; } - public onRoomKeyWithheldEvent?: (event: MatrixEvent) => Promise; - public sendSharedHistoryInboundSessions?: (devicesByUser: Record) => Promise; + public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise; + public sendSharedHistoryInboundSessions?(devicesByUser: Record): Promise; } /** @@ -237,7 +241,7 @@ export abstract class DecryptionAlgorithm { export class DecryptionError extends Error { public readonly detailedString: string; - constructor(public readonly code: string, msg: string, details: Record) { + constructor(public readonly code: string, msg: string, details?: Record) { super(msg); this.code = code; this.name = 'DecryptionError'; @@ -245,7 +249,7 @@ export class DecryptionError extends Error { } } -function detailedStringForDecryptionError(err: DecryptionError, details: Record): string { +function detailedStringForDecryptionError(err: DecryptionError, details?: Record): string { let result = err.name + '[msg: ' + err.message; if (details) { @@ -289,8 +293,8 @@ export class UnknownDeviceError extends Error { */ export function registerAlgorithm( algorithm: string, - encryptor: EncryptionAlgorithm, - decryptor: DecryptionAlgorithm, + encryptor: new (params: IParams) => EncryptionAlgorithm, + decryptor: new (params: Omit) => DecryptionAlgorithm, ): void { ENCRYPTION_CLASSES[algorithm] = encryptor; DECRYPTION_CLASSES[algorithm] = decryptor; diff --git a/src/crypto/algorithms/index.js b/src/crypto/algorithms/index.ts similarity index 88% rename from src/crypto/algorithms/index.js rename to src/crypto/algorithms/index.ts index 0fb646cfe..3dd1158a0 100644 --- a/src/crypto/algorithms/index.js +++ b/src/crypto/algorithms/index.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016 - 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. diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js deleted file mode 100644 index f457e6e6d..000000000 --- a/src/crypto/algorithms/megolm.js +++ /dev/null @@ -1,1788 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd -Copyright 2020 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. -*/ - -/** - * Defines m.olm encryption/decryption - * - * @module crypto/algorithms/megolm - */ - -import { logger } from '../../logger'; -import * as utils from "../../utils"; -import { polyfillSuper } from "../../utils"; -import * as olmlib from "../olmlib"; -import { - DecryptionAlgorithm, - DecryptionError, - EncryptionAlgorithm, - registerAlgorithm, - UnknownDeviceError, -} from "./base"; - -import { WITHHELD_MESSAGES } from '../OlmDevice'; - -// determine whether the key can be shared with invitees -export function isRoomSharedHistory(room) { - const visibilityEvent = room.currentState && - room.currentState.getStateEvents("m.room.history_visibility", ""); - // NOTE: if the room visibility is unset, it would normally default to - // "world_readable". - // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) - // But we will be paranoid here, and treat it as a situation where the room - // is not shared-history - const visibility = visibilityEvent && visibilityEvent.getContent() && - visibilityEvent.getContent().history_visibility; - return ["world_readable", "shared"].includes(visibility); -} - -/** - * @private - * @constructor - * - * @param {string} sessionId - * @param {boolean} sharedHistory whether the session can be freely shared with - * other group members, according to the room history visibility settings - * - * @property {string} sessionId - * @property {Number} useCount number of times this session has been used - * @property {Number} creationTime when the session was created (ms since the epoch) - * - * @property {object} sharedWithDevices - * devices with which we have shared the session key - * userId -> {deviceId -> msgindex} - */ -function OutboundSessionInfo(sessionId, sharedHistory = false) { - this.sessionId = sessionId; - this.useCount = 0; - this.creationTime = new Date().getTime(); - this.sharedWithDevices = {}; - this.blockedDevicesNotified = {}; - this.sharedHistory = sharedHistory; -} - -/** - * Check if it's time to rotate the session - * - * @param {Number} rotationPeriodMsgs - * @param {Number} rotationPeriodMs - * @return {Boolean} - */ -OutboundSessionInfo.prototype.needsRotation = function( - rotationPeriodMsgs, rotationPeriodMs, -) { - const sessionLifetime = new Date().getTime() - this.creationTime; - - if (this.useCount >= rotationPeriodMsgs || - sessionLifetime >= rotationPeriodMs - ) { - logger.log( - "Rotating megolm session after " + this.useCount + - " messages, " + sessionLifetime + "ms", - ); - return true; - } - - return false; -}; - -OutboundSessionInfo.prototype.markSharedWithDevice = function( - userId, deviceId, chainIndex, -) { - if (!this.sharedWithDevices[userId]) { - this.sharedWithDevices[userId] = {}; - } - this.sharedWithDevices[userId][deviceId] = chainIndex; -}; - -OutboundSessionInfo.prototype.markNotifiedBlockedDevice = function( - userId, deviceId, -) { - if (!this.blockedDevicesNotified[userId]) { - this.blockedDevicesNotified[userId] = {}; - } - this.blockedDevicesNotified[userId][deviceId] = true; -}; - -/** - * Determine if this session has been shared with devices which it shouldn't - * have been. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - * - * @return {Boolean} true if we have shared the session with devices which aren't - * in devicesInRoom. - */ -OutboundSessionInfo.prototype.sharedWithTooManyDevices = function( - devicesInRoom, -) { - for (const userId in this.sharedWithDevices) { - if (!this.sharedWithDevices.hasOwnProperty(userId)) { - continue; - } - - if (!devicesInRoom.hasOwnProperty(userId)) { - logger.log("Starting new megolm session because we shared with " + userId); - return true; - } - - for (const deviceId in this.sharedWithDevices[userId]) { - if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) { - continue; - } - - if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { - logger.log( - "Starting new megolm session because we shared with " + - userId + ":" + deviceId, - ); - return true; - } - } - } -}; - -/** - * Megolm encryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/EncryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/EncryptionAlgorithm} - */ -function MegolmEncryption(params) { - polyfillSuper(this, EncryptionAlgorithm, params); - - // the most recent attempt to set up a session. This is used to serialise - // the session setups, so that we have a race-free view of which session we - // are using, and which devices we have shared the keys with. It resolves - // with an OutboundSessionInfo (or undefined, for the first message in the - // room). - this._setupPromise = Promise.resolve(); - - // Map of outbound sessions by sessions ID. Used if we need a particular - // session (the session we're currently using to send is always obtained - // using _setupPromise). - this._outboundSessions = {}; - - // default rotation periods - this._sessionRotationPeriodMsgs = 100; - this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000; - - if (params.config.rotation_period_ms !== undefined) { - this._sessionRotationPeriodMs = params.config.rotation_period_ms; - } - - if (params.config.rotation_period_msgs !== undefined) { - this._sessionRotationPeriodMsgs = params.config.rotation_period_msgs; - } -} -utils.inherits(MegolmEncryption, EncryptionAlgorithm); - -/** - * @private - * - * @param {module:models/room} room - * @param {Object} devicesInRoom The devices in this room, indexed by user ID - * @param {Object} blocked The devices that are blocked, indexed by user ID - * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm - * session creation - * - * @return {Promise} Promise which resolves to the - * OutboundSessionInfo when setup is complete. - */ -MegolmEncryption.prototype._ensureOutboundSession = async function( - room, devicesInRoom, blocked, singleOlmCreationPhase, -) { - let session; - - // takes the previous OutboundSessionInfo, and considers whether to create - // a new one. Also shares the key with any (new) devices in the room. - // Updates `session` to hold the final OutboundSessionInfo. - // - // returns a promise which resolves once the keyshare is successful. - const prepareSession = async (oldSession) => { - session = oldSession; - - const sharedHistory = isRoomSharedHistory(room); - - // history visibility changed - if (session && sharedHistory !== session.sharedHistory) { - session = null; - } - - // need to make a brand new session? - if (session && session.needsRotation(this._sessionRotationPeriodMsgs, - this._sessionRotationPeriodMs) - ) { - logger.log("Starting new megolm session because we need to rotate."); - session = null; - } - - // determine if we have shared with anyone we shouldn't have - if (session && session.sharedWithTooManyDevices(devicesInRoom)) { - session = null; - } - - if (!session) { - logger.log(`Starting new megolm session for room ${this._roomId}`); - session = await this._prepareNewSession(sharedHistory); - logger.log(`Started new megolm session ${session.sessionId} ` + - `for room ${this._roomId}`); - this._outboundSessions[session.sessionId] = session; - } - - // now check if we need to share with any devices - const shareMap = {}; - - for (const [userId, userDevices] of Object.entries(devicesInRoom)) { - for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { - const key = deviceInfo.getIdentityKey(); - if (key == this._olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - - if ( - !session.sharedWithDevices[userId] || - session.sharedWithDevices[userId][deviceId] === undefined - ) { - shareMap[userId] = shareMap[userId] || []; - shareMap[userId].push(deviceInfo); - } - } - } - - const key = this._olmDevice.getOutboundGroupSessionKey(session.sessionId); - const payload = { - type: "m.room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this._roomId, - "session_id": session.sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "org.matrix.msc3061.shared_history": sharedHistory, - }, - }; - const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( - this._olmDevice, this._baseApis, shareMap, - ); - - await Promise.all([ - (async () => { - // share keys with devices that we already have a session for - logger.debug(`Sharing keys with existing Olm sessions in ${this._roomId}`); - await this._shareKeyWithOlmSessions( - session, key, payload, olmSessions, - ); - logger.debug(`Shared keys with existing Olm sessions in ${this._roomId}`); - })(), - (async () => { - logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this._roomId}`); - const errorDevices = []; - - // meanwhile, establish olm sessions for devices that we don't - // already have a session for, and share keys with them. If - // we're doing two phases of olm session creation, use a - // shorter timeout when fetching one-time keys for the first - // phase. - const start = Date.now(); - const failedServers = []; - await this._shareKeyWithDevices( - session, key, payload, devicesWithoutSession, errorDevices, - singleOlmCreationPhase ? 10000 : 2000, failedServers, - ); - logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this._roomId}`); - - if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { - // perform the second phase of olm session creation if requested, - // and if the first phase didn't take too long - (async () => { - // Retry sending keys to devices that we were unable to establish - // an olm session for. This time, we use a longer timeout, but we - // do this in the background and don't block anything else while we - // do this. We only need to retry users from servers that didn't - // respond the first time. - const retryDevices = {}; - const failedServerMap = new Set; - for (const server of failedServers) { - failedServerMap.add(server); - } - const failedDevices = []; - for (const { userId, deviceInfo } of errorDevices) { - const userHS = userId.slice(userId.indexOf(":") + 1); - if (failedServerMap.has(userHS)) { - retryDevices[userId] = retryDevices[userId] || []; - retryDevices[userId].push(deviceInfo); - } else { - // if we aren't going to retry, then handle it - // as a failed device - failedDevices.push({ userId, deviceInfo }); - } - } - - logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this._roomId}`); - await this._shareKeyWithDevices( - session, key, payload, retryDevices, failedDevices, 30000, - ); - logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this._roomId}`); - - await this._notifyFailedOlmDevices(session, key, failedDevices); - })(); - } else { - await this._notifyFailedOlmDevices(session, key, errorDevices); - } - logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this._roomId}`); - })(), - (async () => { - logger.debug(`Notifying blocked devices in ${this._roomId}`); - // also, notify blocked devices that they're blocked - const blockedMap = {}; - let blockedCount = 0; - for (const [userId, userBlockedDevices] of Object.entries(blocked)) { - for (const [deviceId, device] of Object.entries(userBlockedDevices)) { - if ( - !session.blockedDevicesNotified[userId] || - session.blockedDevicesNotified[userId][deviceId] === undefined - ) { - blockedMap[userId] = blockedMap[userId] || {}; - blockedMap[userId][deviceId] = { device }; - blockedCount++; - } - } - } - - await this._notifyBlockedDevices(session, blockedMap); - logger.debug(`Notified ${blockedCount} blocked devices in ${this._roomId}`); - })(), - ]); - }; - - // helper which returns the session prepared by prepareSession - function returnSession() { - return session; - } - - // first wait for the previous share to complete - const prom = this._setupPromise.then(prepareSession); - - // Ensure any failures are logged for debugging - prom.catch(e => { - logger.error(`Failed to ensure outbound session in ${this._roomId}`, e); - }); - - // _setupPromise resolves to `session` whether or not the share succeeds - this._setupPromise = prom.then(returnSession, returnSession); - - // but we return a promise which only resolves if the share was successful. - return prom.then(returnSession); -}; - -/** - * @private - * - * @param {boolean} sharedHistory - * - * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session - */ -MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) { - const sessionId = this._olmDevice.createOutboundGroupSession(); - const key = this._olmDevice.getOutboundGroupSessionKey(sessionId); - - await this._olmDevice.addInboundGroupSession( - this._roomId, this._olmDevice.deviceCurve25519Key, [], sessionId, - key.key, { ed25519: this._olmDevice.deviceEd25519Key }, false, - { sharedHistory: sharedHistory }, - ); - - // don't wait for it to complete - this._crypto.backupManager.backupGroupSession( - this._olmDevice.deviceCurve25519Key, sessionId, - ); - - return new OutboundSessionInfo(sessionId, sharedHistory); -}; - -/** - * Determines what devices in devicesByUser don't have an olm session as given - * in devicemap. - * - * @private - * - * @param {object} devicemap the devices that have olm sessions, as returned by - * olmlib.ensureOlmSessionsForDevices. - * @param {object} devicesByUser a map of user IDs to array of deviceInfo - * @param {array} [noOlmDevices] an array to fill with devices that don't have - * olm sessions - * - * @return {array} an array of devices that don't have olm sessions. If - * noOlmDevices is specified, then noOlmDevices will be returned. - */ -MegolmEncryption.prototype._getDevicesWithoutSessions = function( - devicemap, devicesByUser, noOlmDevices, -) { - noOlmDevices = noOlmDevices || []; - - for (const [userId, devicesToShareWith] of Object.entries(devicesByUser)) { - const sessionResults = devicemap[userId]; - - for (const deviceInfo of devicesToShareWith) { - const deviceId = deviceInfo.deviceId; - - const sessionResult = sessionResults[deviceId]; - if (!sessionResult.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - - noOlmDevices.push({ userId, deviceInfo }); - delete sessionResults[deviceId]; - - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - continue; - } - } - } - - return noOlmDevices; -}; - -/** - * Splits the user device map into multiple chunks to reduce the number of - * devices we encrypt to per API call. - * - * @private - * - * @param {object} devicesByUser map from userid to list of devices - * - * @return {array>} the blocked devices, split into chunks - */ -MegolmEncryption.prototype._splitDevices = function(devicesByUser) { - const maxDevicesPerRequest = 20; - - // use an array where the slices of a content map gets stored - let currentSlice = []; - const mapSlices = [currentSlice]; - - for (const [userId, userDevices] of Object.entries(devicesByUser)) { - for (const deviceInfo of Object.values(userDevices)) { - currentSlice.push({ - userId: userId, - deviceInfo: deviceInfo.device, - }); - } - - // We do this in the per-user loop as we prefer that all messages to the - // same user end up in the same API call to make it easier for the - // server (e.g. only have to send one EDU if a remote user, etc). This - // does mean that if a user has many devices we may go over the desired - // limit, but its not a hard limit so that is fine. - if (currentSlice.length > maxDevicesPerRequest) { - // the current slice is filled up. Start inserting into the next slice - currentSlice = []; - mapSlices.push(currentSlice); - } - } - if (currentSlice.length === 0) { - mapSlices.pop(); - } - return mapSlices; -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {number} chainIndex current chain index - * - * @param {object} userDeviceMap - * mapping from userId to deviceInfo - * - * @param {object} payload fields to include in the encrypted payload - * - * @return {Promise} Promise which resolves once the key sharing - * for the given userDeviceMap is generated and has been sent. - */ -MegolmEncryption.prototype._encryptAndSendKeysToDevices = function( - session, chainIndex, userDeviceMap, payload, -) { - const contentMap = {}; - - const promises = []; - for (let i = 0; i < userDeviceMap.length; i++) { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - const val = userDeviceMap[i]; - const userId = val.userId; - const deviceInfo = val.deviceInfo; - const deviceId = deviceInfo.deviceId; - - if (!contentMap[userId]) { - contentMap[userId] = {}; - } - contentMap[userId][deviceId] = encryptedContent; - - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ), - ); - } - - return Promise.all(promises).then(() => { - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { - logger.log( - "No ciphertext for device " + - userId + ":" + deviceId + ": pruning", - ); - delete contentMap[userId][deviceId]; - } - } - // No devices left for that user? Strip that too. - if (Object.keys(contentMap[userId]).length === 0) { - logger.log("Pruned all devices for user " + userId); - delete contentMap[userId]; - } - } - - // Is there anything left? - if (Object.keys(contentMap).length === 0) { - logger.log("No users left to send to: aborting"); - return; - } - - return this._baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => { - // store that we successfully uploaded the keys of the current slice - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - session.markSharedWithDevice( - userId, deviceId, chainIndex, - ); - } - } - }); - }); -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {array} userDeviceMap list of blocked devices to notify - * - * @param {object} payload fields to include in the notification payload - * - * @return {Promise} Promise which resolves once the notifications - * for the given userDeviceMap is generated and has been sent. - */ -MegolmEncryption.prototype._sendBlockedNotificationsToDevices = async function( - session, userDeviceMap, payload, -) { - const contentMap = {}; - - for (const val of userDeviceMap) { - const userId = val.userId; - const blockedInfo = val.deviceInfo; - const deviceInfo = blockedInfo.deviceInfo; - const deviceId = deviceInfo.deviceId; - - const message = Object.assign({}, payload); - message.code = blockedInfo.code; - message.reason = blockedInfo.reason; - if (message.code === "m.no_olm") { - delete message.room_id; - delete message.session_id; - } - - if (!contentMap[userId]) { - contentMap[userId] = {}; - } - contentMap[userId][deviceId] = message; - } - - await this._baseApis.sendToDevice("org.matrix.room_key.withheld", contentMap); - - // store that we successfully uploaded the keys of the current slice - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - session.markNotifiedBlockedDevice(userId, deviceId); - } - } -}; - -/** - * Re-shares a megolm session key with devices if the key has already been - * sent to them. - * - * @param {string} senderKey The key of the originating device for the session - * @param {string} sessionId ID of the outbound session to share - * @param {string} userId ID of the user who owns the target device - * @param {module:crypto/deviceinfo} device The target device - */ -MegolmEncryption.prototype.reshareKeyWithDevice = async function( - senderKey, sessionId, userId, device, -) { - const obSessionInfo = this._outboundSessions[sessionId]; - if (!obSessionInfo) { - logger.debug(`megolm session ${sessionId} not found: not re-sharing keys`); - return; - } - - // The chain index of the key we previously sent this device - if (obSessionInfo.sharedWithDevices[userId] === undefined) { - logger.debug(`megolm session ${sessionId} never shared with user ${userId}`); - return; - } - const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; - if (sentChainIndex === undefined) { - logger.debug( - "megolm session ID " + sessionId + " never shared with device " + - userId + ":" + device.deviceId, - ); - return; - } - - // get the key from the inbound session: the outbound one will already - // have been ratcheted to the next chain index. - const key = await this._olmDevice.getInboundGroupSessionKey( - this._roomId, senderKey, sessionId, sentChainIndex, - ); - - if (!key) { - logger.warn( - `No inbound session key found for megolm ${sessionId}: not re-sharing keys`, - ); - return; - } - - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { - [userId]: [device], - }, - ); - - const payload = { - type: "m.forwarded_room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this._roomId, - "session_id": sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "sender_key": senderKey, - "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, - "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": key.shared_history || false, - }, - }; - - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - device, - payload, - ); - - await this._baseApis.sendToDevice("m.room.encrypted", { - [userId]: { - [device.deviceId]: encryptedContent, - }, - }); - logger.debug(`Re-shared key for megolm session ${sessionId} ` + - `with ${userId}:${device.deviceId}`); -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} key the session key as returned by - * OlmDevice.getOutboundGroupSessionKey - * - * @param {object} payload the base to-device message payload for sharing keys - * - * @param {object} devicesByUser - * map from userid to list of devices - * - * @param {array} errorDevices - * array that will be populated with the devices that we can't get an - * olm session for - * - * @param {Number} [otkTimeout] The timeout in milliseconds when requesting - * one-time keys for establishing new olm sessions. - * - * @param {Array} [failedServers] An array to fill with remote servers that - * failed to respond to one-time-key requests. - */ -MegolmEncryption.prototype._shareKeyWithDevices = async function( - session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers, -) { - logger.debug(`Ensuring Olm sessions for devices in ${this._roomId}`); - const devicemap = await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, otkTimeout, failedServers, - logger.withPrefix(`[${this._roomId}]`), - ); - logger.debug(`Ensured Olm sessions for devices in ${this._roomId}`); - - this._getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); - - logger.debug(`Sharing keys with Olm sessions in ${this._roomId}`); - await this._shareKeyWithOlmSessions(session, key, payload, devicemap); - logger.debug(`Shared keys with Olm sessions in ${this._roomId}`); -}; - -MegolmEncryption.prototype._shareKeyWithOlmSessions = async function( - session, key, payload, devicemap, -) { - const userDeviceMaps = this._splitDevices(devicemap); - - for (let i = 0; i < userDeviceMaps.length; i++) { - const taskDetail = - `megolm keys for ${session.sessionId} ` + - `in ${this._roomId} (slice ${i + 1}/${userDeviceMaps.length})`; - try { - logger.debug(`Sharing ${taskDetail}`); - await this._encryptAndSendKeysToDevices( - session, key.chain_index, userDeviceMaps[i], payload, - ); - logger.debug(`Shared ${taskDetail}`); - } catch (e) { - logger.error(`Failed to share ${taskDetail}`); - throw e; - } - } -}; - -/** - * Notify devices that we weren't able to create olm sessions. - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} key - * - * @param {Array} failedDevices the devices that we were unable to - * create olm sessions for, as returned by _shareKeyWithDevices - */ -MegolmEncryption.prototype._notifyFailedOlmDevices = async function( - session, key, failedDevices, -) { - logger.debug( - `Notifying ${failedDevices.length} devices we failed to ` + - `create Olm sessions in ${this._roomId}`, - ); - - // mark the devices that failed as "handled" because we don't want to try - // to claim a one-time-key for dead devices on every message. - for (const { userId, deviceInfo } of failedDevices) { - const deviceId = deviceInfo.deviceId; - - session.markSharedWithDevice( - userId, deviceId, key.chain_index, - ); - } - - const filteredFailedDevices = - await this._olmDevice.filterOutNotifiedErrorDevices( - failedDevices, - ); - logger.debug( - `Filtered down to ${filteredFailedDevices.length} error devices ` + - `in ${this._roomId}`, - ); - const blockedMap = {}; - for (const { userId, deviceInfo } of filteredFailedDevices) { - blockedMap[userId] = blockedMap[userId] || {}; - // we use a similar format to what - // olmlib.ensureOlmSessionsForDevices returns, so that - // we can use the same function to split - blockedMap[userId][deviceInfo.deviceId] = { - device: { - code: "m.no_olm", - reason: WITHHELD_MESSAGES["m.no_olm"], - deviceInfo, - }, - }; - } - - // send the notifications - await this._notifyBlockedDevices(session, blockedMap); - logger.debug( - `Notified ${filteredFailedDevices.length} devices we failed to ` + - `create Olm sessions in ${this._roomId}`, - ); -}; - -/** - * Notify blocked devices that they have been blocked. - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} devicesByUser - * map from userid to device ID to blocked data - */ -MegolmEncryption.prototype._notifyBlockedDevices = async function( - session, devicesByUser, -) { - const payload = { - room_id: this._roomId, - session_id: session.sessionId, - algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - }; - - const userDeviceMaps = this._splitDevices(devicesByUser); - - for (let i = 0; i < userDeviceMaps.length; i++) { - try { - await this._sendBlockedNotificationsToDevices( - session, userDeviceMaps[i], payload, - ); - logger.log(`Completed blacklist notification for ${session.sessionId} ` - + `in ${this._roomId} (slice ${i + 1}/${userDeviceMaps.length})`); - } catch (e) { - logger.log(`blacklist notification for ${session.sessionId} in ` - + `${this._roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`); - - throw e; - } - } -}; - -/** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * - * @param {module:models/room} room the room the event is in - */ -MegolmEncryption.prototype.prepareToEncrypt = function(room) { - if (this.encryptionPreparation) { - // We're already preparing something, so don't do anything else. - // FIXME: check if we need to restart - // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; - logger.debug( - `Already started preparing to encrypt for ${this._roomId} ` + - `${elapsedTime} ms ago, skipping`, - ); - return; - } - - logger.debug(`Preparing to encrypt events for ${this._roomId}`); - - this.encryptionPreparationMetadata = { - startTime: Date.now(), - }; - this.encryptionPreparation = (async () => { - try { - logger.debug(`Getting devices in ${this._roomId}`); - const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); - - if (this._crypto.getGlobalErrorOnUnknownDevices()) { - // Drop unknown devices for now. When the message gets sent, we'll - // throw an error, but we'll still be prepared to send to the known - // devices. - this._removeUnknownDevices(devicesInRoom); - } - - logger.debug(`Ensuring outbound session in ${this._roomId}`); - await this._ensureOutboundSession(room, devicesInRoom, blocked, true); - - logger.debug(`Ready to encrypt events for ${this._roomId}`); - } catch (e) { - logger.error(`Failed to prepare to encrypt events for ${this._roomId}`, e); - } finally { - delete this.encryptionPreparationMetadata; - delete this.encryptionPreparation; - } - })(); -}; - -/** - * @inheritdoc - * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content plaintext event content - * - * @return {Promise} Promise which resolves to the new event body - */ -MegolmEncryption.prototype.encryptMessage = async function(room, eventType, content) { - logger.log(`Starting to encrypt event for ${this._roomId}`); - - if (this.encryptionPreparation) { - // If we started sending keys, wait for it to be done. - // FIXME: check if we need to cancel - // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - try { - await this.encryptionPreparation; - } catch (e) { - // ignore any errors -- if the preparation failed, we'll just - // restart everything here - } - } - - const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); - - // check if any of these devices are not yet known to the user. - // if so, warn the user so they can verify or ignore. - if (this._crypto.getGlobalErrorOnUnknownDevices()) { - this._checkForUnknownDevices(devicesInRoom); - } - - const session = await this._ensureOutboundSession(room, devicesInRoom, blocked); - const payloadJson = { - room_id: this._roomId, - type: eventType, - content: content, - }; - - const ciphertext = this._olmDevice.encryptGroupMessage( - session.sessionId, JSON.stringify(payloadJson), - ); - const encryptedContent = { - algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: ciphertext, - session_id: session.sessionId, - // Include our device ID so that recipients can send us a - // m.new_device message if they don't have our session key. - // XXX: Do we still need this now that m.new_device messages - // no longer exist since #483? - device_id: this._deviceId, - }; - - session.useCount++; - return encryptedContent; -}; - -/** - * Forces the current outbound group session to be discarded such - * that another one will be created next time an event is sent. - * - * This should not normally be necessary. - */ -MegolmEncryption.prototype.forceDiscardSession = function() { - this._setupPromise = this._setupPromise.then(() => null); -}; - -/** - * Checks the devices we're about to send to and see if any are entirely - * unknown to the user. If so, warn the user, and mark them as known to - * give the user a chance to go verify them before re-sending this message. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - */ -MegolmEncryption.prototype._checkForUnknownDevices = function(devicesInRoom) { - const unknownDevices = {}; - - Object.keys(devicesInRoom).forEach((userId)=>{ - Object.keys(devicesInRoom[userId]).forEach((deviceId)=>{ - const device = devicesInRoom[userId][deviceId]; - if (device.isUnverified() && !device.isKnown()) { - if (!unknownDevices[userId]) { - unknownDevices[userId] = {}; - } - unknownDevices[userId][deviceId] = device; - } - }); - }); - - if (Object.keys(unknownDevices).length) { - // it'd be kind to pass unknownDevices up to the user in this error - throw new UnknownDeviceError( - "This room contains unknown devices which have not been verified. " + - "We strongly recommend you verify them before continuing.", unknownDevices); - } -}; - -/** - * Remove unknown devices from a set of devices. The devicesInRoom parameter - * will be modified. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - */ -MegolmEncryption.prototype._removeUnknownDevices = function(devicesInRoom) { - for (const [userId, userDevices] of Object.entries(devicesInRoom)) { - for (const [deviceId, device] of Object.entries(userDevices)) { - if (device.isUnverified() && !device.isKnown()) { - delete userDevices[deviceId]; - } - } - - if (Object.keys(userDevices).length === 0) { - delete devicesInRoom[userId]; - } - } -}; - -/** - * Get the list of unblocked devices for all users in the room - * - * @param {module:models/room} room - * - * @return {Promise} Promise which resolves to an array whose - * first element is a map from userId to deviceId to deviceInfo indicating - * the devices that messages should be encrypted to, and whose second - * element is a map from userId to deviceId to data indicating the devices - * that are in the room but that have been blocked - */ -MegolmEncryption.prototype._getDevicesInRoom = async function(room) { - const members = await room.getEncryptionTargetMembers(); - const roomMembers = members.map(function(u) { - return u.userId; - }); - - // The global value is treated as a default for when rooms don't specify a value. - let isBlacklisting = this._crypto.getGlobalBlacklistUnverifiedDevices(); - if (typeof room.getBlacklistUnverifiedDevices() === 'boolean') { - isBlacklisting = room.getBlacklistUnverifiedDevices(); - } - - // We are happy to use a cached version here: we assume that if we already - // have a list of the user's devices, then we already share an e2e room - // with them, which means that they will have announced any new devices via - // device_lists in their /sync response. This cache should then be maintained - // using all the device_lists changes and left fields. - // See https://github.com/vector-im/element-web/issues/2305 for details. - const devices = await this._crypto.downloadKeys(roomMembers, false); - const blocked = {}; - // remove any blocked devices - for (const userId in devices) { - if (!devices.hasOwnProperty(userId)) { - continue; - } - - const userDevices = devices[userId]; - for (const deviceId in userDevices) { - if (!userDevices.hasOwnProperty(deviceId)) { - continue; - } - - const deviceTrust = this._crypto.checkDeviceTrust(userId, deviceId); - - if (userDevices[deviceId].isBlocked() || - (!deviceTrust.isVerified() && isBlacklisting) - ) { - if (!blocked[userId]) { - blocked[userId] = {}; - } - const blockedInfo = userDevices[deviceId].isBlocked() - ? { - code: "m.blacklisted", - reason: WITHHELD_MESSAGES["m.blacklisted"], - } - : { - code: "m.unverified", - reason: WITHHELD_MESSAGES["m.unverified"], - }; - blockedInfo.deviceInfo = userDevices[deviceId]; - blocked[userId][deviceId] = blockedInfo; - delete userDevices[deviceId]; - } - } - } - - return [devices, blocked]; -}; - -/** - * Megolm decryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/DecryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/DecryptionAlgorithm} - */ -function MegolmDecryption(params) { - polyfillSuper(this, DecryptionAlgorithm, params); - - // events which we couldn't decrypt due to unknown sessions / indexes: map from - // senderKey|sessionId to Set of MatrixEvents - this._pendingEvents = {}; - - // this gets stubbed out by the unit tests. - this.olmlib = olmlib; -} -utils.inherits(MegolmDecryption, DecryptionAlgorithm); - -const PROBLEM_DESCRIPTIONS = { - no_olm: "The sender was unable to establish a secure channel.", - unknown: "The secure channel with the sender was corrupted.", -}; - -/** - * @inheritdoc - * - * @param {MatrixEvent} event - * - * returns a promise which resolves to a - * {@link module:crypto~EventDecryptionResult} once we have finished - * decrypting, or rejects with an `algorithms.DecryptionError` if there is a - * problem decrypting the event. - */ -MegolmDecryption.prototype.decryptEvent = async function(event) { - const content = event.getWireContent(); - - if (!content.sender_key || !content.session_id || - !content.ciphertext - ) { - throw new DecryptionError( - "MEGOLM_MISSING_FIELDS", - "Missing fields in input", - ); - } - - // we add the event to the pending list *before* we start decryption. - // - // then, if the key turns up while decryption is in progress (and - // decryption fails), we will schedule a retry. - // (fixes https://github.com/vector-im/element-web/issues/5001) - this._addEventToPendingList(event); - - let res; - try { - res = await this._olmDevice.decryptGroupMessage( - event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, - event.getId(), event.getTs(), - ); - } catch (e) { - if (e.name === "DecryptionError") { - // re-throw decryption errors as-is - throw e; - } - - let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; - - if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { - this._requestKeysForEvent(event); - - errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX'; - } - - throw new DecryptionError( - errorCode, - e ? e.toString() : "Unknown Error: Error is undefined", { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - if (res === null) { - // We've got a message for a session we don't have. - // - // (XXX: We might actually have received this key since we started - // decrypting, in which case we'll have scheduled a retry, and this - // request will be redundant. We could probably check to see if the - // event is still in the pending list; if not, a retry will have been - // scheduled, so we needn't send out the request here.) - this._requestKeysForEvent(event); - - // See if there was a problem with the olm session at the time the - // event was sent. Use a fuzz factor of 2 minutes. - const problem = await this._olmDevice.sessionMayHaveProblems( - content.sender_key, event.getTs() - 120000, - ); - if (problem) { - let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] - || PROBLEM_DESCRIPTIONS.unknown; - if (problem.fixed) { - problemDescription += - " Trying to create a new secure channel and re-requesting the keys."; - } - throw new DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - problemDescription, - { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - throw new DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - "The sender's device has not sent us the keys for this message.", - { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - // success. We can remove the event from the pending list, if that hasn't - // already happened. - this._removeEventFromPendingList(event); - - const payload = JSON.parse(res.result); - - // belt-and-braces check that the room id matches that indicated by the HS - // (this is somewhat redundant, since the megolm session is scoped to the - // room, so neither the sender nor a MITM can lie about the room_id). - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - "MEGOLM_BAD_ROOM", - "Message intended for room " + payload.room_id, - ); - } - - return { - clearEvent: payload, - senderCurve25519Key: res.senderKey, - claimedEd25519Key: res.keysClaimed.ed25519, - forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, - untrusted: res.untrusted, - }; -}; - -MegolmDecryption.prototype._requestKeysForEvent = function(event) { - const wireContent = event.getWireContent(); - - const recipients = event.getKeyRequestRecipients(this._userId); - - this._crypto.requestRoomKey({ - room_id: event.getRoomId(), - algorithm: wireContent.algorithm, - sender_key: wireContent.sender_key, - session_id: wireContent.session_id, - }, recipients); -}; - -/** - * Add an event to the list of those awaiting their session keys. - * - * @private - * - * @param {module:models/event.MatrixEvent} event - */ -MegolmDecryption.prototype._addEventToPendingList = function(event) { - const content = event.getWireContent(); - const senderKey = content.sender_key; - const sessionId = content.session_id; - if (!this._pendingEvents[senderKey]) { - this._pendingEvents[senderKey] = new Map(); - } - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents.has(sessionId)) { - senderPendingEvents.set(sessionId, new Set()); - } - senderPendingEvents.get(sessionId).add(event); -}; - -/** - * Remove an event from the list of those awaiting their session keys. - * - * @private - * - * @param {module:models/event.MatrixEvent} event - */ -MegolmDecryption.prototype._removeEventFromPendingList = function(event) { - const content = event.getWireContent(); - const senderKey = content.sender_key; - const sessionId = content.session_id; - const senderPendingEvents = this._pendingEvents[senderKey]; - const pendingEvents = senderPendingEvents && senderPendingEvents.get(sessionId); - if (!pendingEvents) { - return; - } - - pendingEvents.delete(event); - if (pendingEvents.size === 0) { - senderPendingEvents.delete(senderKey); - } - if (senderPendingEvents.size === 0) { - delete this._pendingEvents[senderKey]; - } -}; - -/** - * @inheritdoc - * - * @param {module:models/event.MatrixEvent} event key event - */ -MegolmDecryption.prototype.onRoomKeyEvent = function(event) { - const content = event.getContent(); - const sessionId = content.session_id; - let senderKey = event.getSenderKey(); - let forwardingKeyChain = []; - let exportFormat = false; - let keysClaimed; - - if (!content.room_id || - !sessionId || - !content.session_key - ) { - logger.error("key event is missing fields"); - return; - } - - if (!senderKey) { - logger.error("key event has no sender key (not encrypted?)"); - return; - } - - if (event.getType() == "m.forwarded_room_key") { - exportFormat = true; - forwardingKeyChain = content.forwarding_curve25519_key_chain; - if (!Array.isArray(forwardingKeyChain)) { - forwardingKeyChain = []; - } - - // copy content before we modify it - forwardingKeyChain = forwardingKeyChain.slice(); - forwardingKeyChain.push(senderKey); - - senderKey = content.sender_key; - if (!senderKey) { - logger.error("forwarded_room_key event is missing sender_key field"); - return; - } - - const ed25519Key = content.sender_claimed_ed25519_key; - if (!ed25519Key) { - logger.error( - `forwarded_room_key_event is missing sender_claimed_ed25519_key field`, - ); - return; - } - - keysClaimed = { - ed25519: ed25519Key, - }; - } else { - keysClaimed = event.getKeysClaimed(); - } - - const extraSessionData = {}; - if (content["org.matrix.msc3061.shared_history"]) { - extraSessionData.sharedHistory = true; - } - return this._olmDevice.addInboundGroupSession( - content.room_id, senderKey, forwardingKeyChain, sessionId, - content.session_key, keysClaimed, - exportFormat, extraSessionData, - ).then(() => { - // have another go at decrypting events sent with this session. - this._retryDecryption(senderKey, sessionId) - .then((success) => { - // cancel any outstanding room key requests for this session. - // Only do this if we managed to decrypt every message in the - // session, because if we didn't, we leave the other key - // requests in the hopes that someone sends us a key that - // includes an earlier index. - if (success) { - this._crypto.cancelRoomKeyRequest({ - algorithm: content.algorithm, - room_id: content.room_id, - session_id: content.session_id, - sender_key: senderKey, - }); - } - }); - }).then(() => { - // don't wait for the keys to be backed up for the server - this._crypto.backupManager.backupGroupSession(senderKey, content.session_id); - }).catch((e) => { - logger.error(`Error handling m.room_key_event: ${e}`); - }); -}; - -/** - * @inheritdoc - * - * @param {module:models/event.MatrixEvent} event key event - */ -MegolmDecryption.prototype.onRoomKeyWithheldEvent = async function(event) { - const content = event.getContent(); - const senderKey = content.sender_key; - - if (content.code === "m.no_olm") { - const sender = event.getSender(); - logger.warn( - `${sender}:${senderKey} was unable to establish an olm session with us`, - ); - // if the sender says that they haven't been able to establish an olm - // session, let's proactively establish one - - // Note: after we record that the olm session has had a problem, we - // trigger retrying decryption for all the messages from the sender's - // key, so that we can update the error message to indicate the olm - // session problem. - - if (await this._olmDevice.getSessionIdForDevice(senderKey)) { - // a session has already been established, so we don't need to - // create a new one. - logger.debug("New session already created. Not creating a new one."); - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); - this.retryDecryptionFromSender(senderKey); - return; - } - let device = this._crypto.deviceList.getDeviceByIdentityKey( - content.algorithm, senderKey, - ); - if (!device) { - // if we don't know about the device, fetch the user's devices again - // and retry before giving up - await this._crypto.downloadKeys([sender], false); - device = this._crypto.deviceList.getDeviceByIdentityKey( - content.algorithm, senderKey, - ); - if (!device) { - logger.info( - "Couldn't find device for identity key " + senderKey + - ": not establishing session", - ); - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", false); - this.retryDecryptionFromSender(senderKey); - return; - } - } - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { [sender]: [device] }, false, - ); - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - sender, - device, - { type: "m.dummy" }, - ); - - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); - this.retryDecryptionFromSender(senderKey); - - await this._baseApis.sendToDevice("m.room.encrypted", { - [sender]: { - [device.deviceId]: encryptedContent, - }, - }); - } else { - await this._olmDevice.addInboundGroupSessionWithheld( - content.room_id, senderKey, content.session_id, content.code, - content.reason, - ); - } -}; - -/** - * @inheritdoc - */ -MegolmDecryption.prototype.hasKeysForKeyRequest = function(keyRequest) { - const body = keyRequest.requestBody; - - return this._olmDevice.hasInboundSessionKeys( - body.room_id, - body.sender_key, - body.session_id, - // TODO: ratchet index - ); -}; - -/** - * @inheritdoc - */ -MegolmDecryption.prototype.shareKeysWithDevice = function(keyRequest) { - const userId = keyRequest.userId; - const deviceId = keyRequest.deviceId; - const deviceInfo = this._crypto.getStoredDevice(userId, deviceId); - const body = keyRequest.requestBody; - - this.olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { - [userId]: [deviceInfo], - }, - ).then((devicemap) => { - const olmSessionResult = devicemap[userId][deviceId]; - if (!olmSessionResult.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - // - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - return null; - } - - logger.log( - "sharing keys for session " + body.sender_key + "|" - + body.session_id + " with device " - + userId + ":" + deviceId, - ); - - return this._buildKeyForwardingMessage( - body.room_id, body.sender_key, body.session_id, - ); - }).then((payload) => { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - - return this.olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ).then(() => { - const contentMap = { - [userId]: { - [deviceId]: encryptedContent, - }, - }; - - // TODO: retries - return this._baseApis.sendToDevice("m.room.encrypted", contentMap); - }); - }); -}; - -MegolmDecryption.prototype._buildKeyForwardingMessage = async function( - roomId, senderKey, sessionId, -) { - const key = await this._olmDevice.getInboundGroupSessionKey( - roomId, senderKey, sessionId, - ); - - return { - type: "m.forwarded_room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": roomId, - "sender_key": senderKey, - "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, - "session_id": sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": key.shared_history || false, - }, - }; -}; - -/** - * @inheritdoc - * - * @param {module:crypto/OlmDevice.MegolmSessionData} session - * @param {object} [opts={}] options for the import - * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted - * @param {string} [opts.source] where the key came from - */ -MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) { - const extraSessionData = {}; - if (opts.untrusted) { - extraSessionData.untrusted = true; - } - if (session["org.matrix.msc3061.shared_history"]) { - extraSessionData.sharedHistory = true; - } - return this._olmDevice.addInboundGroupSession( - session.room_id, - session.sender_key, - session.forwarding_curve25519_key_chain, - session.session_id, - session.session_key, - session.sender_claimed_keys, - true, - extraSessionData, - ).then(() => { - if (opts.source !== "backup") { - // don't wait for it to complete - 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. - logger.log("Failed to back up megolm session", e); - }); - } - // have another go at decrypting events sent with this session. - this._retryDecryption(session.sender_key, session.session_id); - }); -}; - -/** - * Have another go at decrypting events after we receive a key. Resolves once - * decryption has been re-attempted on all events. - * - * @private - * @param {String} senderKey - * @param {String} sessionId - * - * @return {Boolean} whether all messages were successfully decrypted - */ -MegolmDecryption.prototype._retryDecryption = async function(senderKey, sessionId) { - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents) { - return true; - } - - const pending = senderPendingEvents.get(sessionId); - if (!pending) { - return true; - } - - logger.debug("Retrying decryption on events", [...pending]); - - await Promise.all([...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this._crypto, { isRetry: true }); - } catch (e) { - // don't die if something goes wrong - } - })); - - // If decrypted successfully, they'll have been removed from _pendingEvents - return !((this._pendingEvents[senderKey] || {})[sessionId]); -}; - -MegolmDecryption.prototype.retryDecryptionFromSender = async function(senderKey) { - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents) { - return true; - } - - delete this._pendingEvents[senderKey]; - - await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { - await Promise.all([...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this._crypto); - } catch (e) { - // don't die if something goes wrong - } - })); - })); - - return !this._pendingEvents[senderKey]; -}; - -MegolmDecryption.prototype.sendSharedHistoryInboundSessions = async function(devicesByUser) { - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, - ); - - logger.log("sendSharedHistoryInboundSessions to users", Object.keys(devicesByUser)); - - const sharedHistorySessions = - await this._olmDevice.getSharedHistoryInboundGroupSessions( - this._roomId, - ); - logger.log("shared-history sessions", sharedHistorySessions); - for (const [senderKey, sessionId] of sharedHistorySessions) { - const payload = await this._buildKeyForwardingMessage( - this._roomId, senderKey, sessionId, - ); - - const promises = []; - const contentMap = {}; - for (const [userId, devices] of Object.entries(devicesByUser)) { - contentMap[userId] = {}; - for (const deviceInfo of devices) { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - contentMap[userId][deviceInfo.deviceId] = encryptedContent; - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ), - ); - } - } - await Promise.all(promises); - - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { - logger.log( - "No ciphertext for device " + - userId + ":" + deviceId + ": pruning", - ); - delete contentMap[userId][deviceId]; - } - } - // No devices left for that user? Strip that too. - if (Object.keys(contentMap[userId]).length === 0) { - logger.log("Pruned all devices for user " + userId); - delete contentMap[userId]; - } - } - - // Is there anything left? - if (Object.keys(contentMap).length === 0) { - logger.log("No users left to send to: aborting"); - return; - } - - await this._baseApis.sendToDevice("m.room.encrypted", contentMap); - } -}; - -registerAlgorithm( - olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption, -); diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts new file mode 100644 index 000000000..af80a9489 --- /dev/null +++ b/src/crypto/algorithms/megolm.ts @@ -0,0 +1,1833 @@ +/* +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. +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. +*/ + +/** + * Defines m.olm encryption/decryption + * + * @module crypto/algorithms/megolm + */ + +import { logger } from '../../logger'; +import * as olmlib from "../olmlib"; +import { + DecryptionAlgorithm, + DecryptionError, + EncryptionAlgorithm, + registerAlgorithm, + UnknownDeviceError, +} from "./base"; +import { WITHHELD_MESSAGES } from '../OlmDevice'; +import { Room } from '../../models/room'; +import { DeviceInfo } from "../deviceinfo"; +import { IOlmSessionResult } from "../olmlib"; +import { DeviceInfoMap } from "../DeviceList"; +import { MatrixEvent } from "../.."; +import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index"; + +// determine whether the key can be shared with invitees +export function isRoomSharedHistory(room: Room): boolean { + const visibilityEvent = room?.currentState?.getStateEvents("m.room.history_visibility", ""); + // NOTE: if the room visibility is unset, it would normally default to + // "world_readable". + // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) + // But we will be paranoid here, and treat it as a situation where the room + // is not shared-history + const visibility = visibilityEvent?.getContent()?.history_visibility; + return ["world_readable", "shared"].includes(visibility); +} + +interface IBlockedDevice { + code: string; + reason: string; + deviceInfo: DeviceInfo; +} + +interface IBlockedMap { + [userId: string]: { + [deviceId: string]: IBlockedDevice; + }; +} + +interface IOlmDevice { + userId: string; + deviceInfo: T; +} + +/* eslint-disable camelcase */ +interface IOutboundGroupSessionKey { + chain_index: number; + key: string; +} + +interface IMessage { + type: string; + content: { + algorithm: string; + room_id: string; + sender_key?: string; + sender_claimed_ed25519_key?: string; + session_id: string; + session_key: string; + chain_index: number; + forwarding_curve25519_key_chain?: string[]; + "org.matrix.msc3061.shared_history": boolean; + }; +} + +interface IKeyForwardingMessage extends IMessage { + type: "m.forwarded_room_key"; +} + +interface IPayload extends Partial { + code?: string; + reason?: string; + room_id?: string; + session_id?: string; + algorithm?: string; + sender_key?: string; +} +/* eslint-enable camelcase */ + +/** + * @private + * @constructor + * + * @param {string} sessionId + * @param {boolean} sharedHistory whether the session can be freely shared with + * other group members, according to the room history visibility settings + * + * @property {string} sessionId + * @property {Number} useCount number of times this session has been used + * @property {Number} creationTime when the session was created (ms since the epoch) + * + * @property {object} sharedWithDevices + * devices with which we have shared the session key + * userId -> {deviceId -> msgindex} + */ +class OutboundSessionInfo { + public useCount = 0; + public creationTime: number; + public sharedWithDevices: Record> = {}; + public blockedDevicesNotified: Record> = {}; + + constructor(public readonly sessionId: string, public readonly sharedHistory = false) { + this.creationTime = new Date().getTime(); + } + + /** + * Check if it's time to rotate the session + * + * @param {Number} rotationPeriodMsgs + * @param {Number} rotationPeriodMs + * @return {Boolean} + */ + public needsRotation(rotationPeriodMsgs: number, rotationPeriodMs: number): boolean { + const sessionLifetime = new Date().getTime() - this.creationTime; + + if (this.useCount >= rotationPeriodMsgs || + sessionLifetime >= rotationPeriodMs + ) { + logger.log( + "Rotating megolm session after " + this.useCount + + " messages, " + sessionLifetime + "ms", + ); + return true; + } + + return false; + } + + public markSharedWithDevice(userId: string, deviceId: string, chainIndex: number): void { + if (!this.sharedWithDevices[userId]) { + this.sharedWithDevices[userId] = {}; + } + this.sharedWithDevices[userId][deviceId] = chainIndex; + } + + public markNotifiedBlockedDevice(userId: string, deviceId: string): void { + if (!this.blockedDevicesNotified[userId]) { + this.blockedDevicesNotified[userId] = {}; + } + this.blockedDevicesNotified[userId][deviceId] = true; + } + + /** + * Determine if this session has been shared with devices which it shouldn't + * have been. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + * + * @return {Boolean} true if we have shared the session with devices which aren't + * in devicesInRoom. + */ + public sharedWithTooManyDevices(devicesInRoom: Record>): boolean { + for (const userId in this.sharedWithDevices) { + if (!this.sharedWithDevices.hasOwnProperty(userId)) { + continue; + } + + if (!devicesInRoom.hasOwnProperty(userId)) { + logger.log("Starting new megolm session because we shared with " + userId); + return true; + } + + for (const deviceId in this.sharedWithDevices[userId]) { + if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) { + continue; + } + + if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { + logger.log( + "Starting new megolm session because we shared with " + + userId + ":" + deviceId, + ); + return true; + } + } + } + } +} + +/** + * Megolm encryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/EncryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/EncryptionAlgorithm} + */ +class MegolmEncryption extends EncryptionAlgorithm { + // the most recent attempt to set up a session. This is used to serialise + // the session setups, so that we have a race-free view of which session we + // are using, and which devices we have shared the keys with. It resolves + // with an OutboundSessionInfo (or undefined, for the first message in the + // room). + private setupPromise = Promise.resolve(undefined); + + // Map of outbound sessions by sessions ID. Used if we need a particular + // session (the session we're currently using to send is always obtained + // using setupPromise). + private outboundSessions: Record = {}; + + private readonly sessionRotationPeriodMsgs: number; + private readonly sessionRotationPeriodMs: number; + private encryptionPreparation: Promise; + private encryptionPreparationMetadata: { + startTime: number; + }; + + constructor(params) { + super(params); + + this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100; + this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000; + } + + /** + * @private + * + * @param {module:models/room} room + * @param {Object} devicesInRoom The devices in this room, indexed by user ID + * @param {Object} blocked The devices that are blocked, indexed by user ID + * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm + * session creation + * + * @return {Promise} Promise which resolves to the + * OutboundSessionInfo when setup is complete. + */ + private async ensureOutboundSession( + room: Room, + devicesInRoom: DeviceInfoMap, + blocked: IBlockedMap, + singleOlmCreationPhase = false, + ): Promise { + let session; + + // takes the previous OutboundSessionInfo, and considers whether to create + // a new one. Also shares the key with any (new) devices in the room. + // Updates `session` to hold the final OutboundSessionInfo. + // + // returns a promise which resolves once the keyshare is successful. + const prepareSession = async (oldSession: OutboundSessionInfo) => { + session = oldSession; + + const sharedHistory = isRoomSharedHistory(room); + + // history visibility changed + if (session && sharedHistory !== session.sharedHistory) { + session = null; + } + + // need to make a brand new session? + if (session && session.needsRotation(this.sessionRotationPeriodMsgs, + this.sessionRotationPeriodMs) + ) { + logger.log("Starting new megolm session because we need to rotate."); + session = null; + } + + // determine if we have shared with anyone we shouldn't have + if (session && session.sharedWithTooManyDevices(devicesInRoom)) { + session = null; + } + + if (!session) { + logger.log(`Starting new megolm session for room ${this.roomId}`); + session = await this.prepareNewSession(sharedHistory); + logger.log(`Started new megolm session ${session.sessionId} ` + + `for room ${this.roomId}`); + this.outboundSessions[session.sessionId] = session; + } + + // now check if we need to share with any devices + const shareMap = {}; + + for (const [userId, userDevices] of Object.entries(devicesInRoom)) { + for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + + if ( + !session.sharedWithDevices[userId] || + session.sharedWithDevices[userId][deviceId] === undefined + ) { + shareMap[userId] = shareMap[userId] || []; + shareMap[userId].push(deviceInfo); + } + } + } + + const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); + const payload: IPayload = { + type: "m.room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": session.sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "org.matrix.msc3061.shared_history": sharedHistory, + }, + }; + const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( + this.olmDevice, this.baseApis, shareMap, + ); + + await Promise.all([ + (async () => { + // share keys with devices that we already have a session for + logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`); + await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); + logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`); + const errorDevices = []; + + // meanwhile, establish olm sessions for devices that we don't + // already have a session for, and share keys with them. If + // we're doing two phases of olm session creation, use a + // shorter timeout when fetching one-time keys for the first + // phase. + const start = Date.now(); + const failedServers = []; + await this.shareKeyWithDevices( + session, key, payload, devicesWithoutSession, errorDevices, + singleOlmCreationPhase ? 10000 : 2000, failedServers, + ); + logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`); + + if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { + // perform the second phase of olm session creation if requested, + // and if the first phase didn't take too long + (async () => { + // Retry sending keys to devices that we were unable to establish + // an olm session for. This time, we use a longer timeout, but we + // do this in the background and don't block anything else while we + // do this. We only need to retry users from servers that didn't + // respond the first time. + const retryDevices = {}; + const failedServerMap = new Set; + for (const server of failedServers) { + failedServerMap.add(server); + } + const failedDevices = []; + for (const { userId, deviceInfo } of errorDevices) { + const userHS = userId.slice(userId.indexOf(":") + 1); + if (failedServerMap.has(userHS)) { + retryDevices[userId] = retryDevices[userId] || []; + retryDevices[userId].push(deviceInfo); + } else { + // if we aren't going to retry, then handle it + // as a failed device + failedDevices.push({ userId, deviceInfo }); + } + } + + logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); + await this.shareKeyWithDevices( + session, key, payload, retryDevices, failedDevices, 30000, + ); + logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`); + + await this.notifyFailedOlmDevices(session, key, failedDevices); + })(); + } else { + await this.notifyFailedOlmDevices(session, key, errorDevices); + } + logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug(`Notifying blocked devices in ${this.roomId}`); + // also, notify blocked devices that they're blocked + const blockedMap: Record> = {}; + let blockedCount = 0; + for (const [userId, userBlockedDevices] of Object.entries(blocked)) { + for (const [deviceId, device] of Object.entries(userBlockedDevices)) { + if ( + !session.blockedDevicesNotified[userId] || + session.blockedDevicesNotified[userId][deviceId] === undefined + ) { + blockedMap[userId] = blockedMap[userId] || {}; + blockedMap[userId][deviceId] = { device }; + blockedCount++; + } + } + } + + await this.notifyBlockedDevices(session, blockedMap); + logger.debug(`Notified ${blockedCount} blocked devices in ${this.roomId}`); + })(), + ]); + }; + + // helper which returns the session prepared by prepareSession + function returnSession() { + return session; + } + + // first wait for the previous share to complete + const prom = this.setupPromise.then(prepareSession); + + // Ensure any failures are logged for debugging + prom.catch(e => { + logger.error(`Failed to ensure outbound session in ${this.roomId}`, e); + }); + + // setupPromise resolves to `session` whether or not the share succeeds + this.setupPromise = prom.then(returnSession, returnSession); + + // but we return a promise which only resolves if the share was successful. + return prom.then(returnSession); + } + + /** + * @private + * + * @param {boolean} sharedHistory + * + * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session + */ + private async prepareNewSession(sharedHistory: boolean): Promise { + const sessionId = this.olmDevice.createOutboundGroupSession(); + const key = this.olmDevice.getOutboundGroupSessionKey(sessionId); + + await this.olmDevice.addInboundGroupSession( + this.roomId, this.olmDevice.deviceCurve25519Key, [], sessionId, + key.key, { ed25519: this.olmDevice.deviceEd25519Key }, false, + { sharedHistory }, + ); + + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession(this.olmDevice.deviceCurve25519Key, sessionId); + + return new OutboundSessionInfo(sessionId, sharedHistory); + } + + /** + * Determines what devices in devicesByUser don't have an olm session as given + * in devicemap. + * + * @private + * + * @param {object} devicemap the devices that have olm sessions, as returned by + * olmlib.ensureOlmSessionsForDevices. + * @param {object} devicesByUser a map of user IDs to array of deviceInfo + * @param {array} [noOlmDevices] an array to fill with devices that don't have + * olm sessions + * + * @return {array} an array of devices that don't have olm sessions. If + * noOlmDevices is specified, then noOlmDevices will be returned. + */ + private getDevicesWithoutSessions( + devicemap: Record>, + devicesByUser: Record, + noOlmDevices: IOlmDevice[] = [], + ): IOlmDevice[] { + for (const [userId, devicesToShareWith] of Object.entries(devicesByUser)) { + const sessionResults = devicemap[userId]; + + for (const deviceInfo of devicesToShareWith) { + const deviceId = deviceInfo.deviceId; + + const sessionResult = sessionResults[deviceId]; + if (!sessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + + noOlmDevices.push({ userId, deviceInfo }); + delete sessionResults[deviceId]; + + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + continue; + } + } + } + + return noOlmDevices; + } + + /** + * Splits the user device map into multiple chunks to reduce the number of + * devices we encrypt to per API call. + * + * @private + * + * @param {object} devicesByUser map from userid to list of devices + * + * @return {array>} the blocked devices, split into chunks + */ + private splitDevices( + devicesByUser: Record>, + ): IOlmDevice[][] { + const maxDevicesPerRequest = 20; + + // use an array where the slices of a content map gets stored + let currentSlice: IOlmDevice[] = []; + const mapSlices = [currentSlice]; + + for (const [userId, userDevices] of Object.entries(devicesByUser)) { + for (const deviceInfo of Object.values(userDevices)) { + currentSlice.push({ + userId: userId, + deviceInfo: deviceInfo.device, + }); + } + + // We do this in the per-user loop as we prefer that all messages to the + // same user end up in the same API call to make it easier for the + // server (e.g. only have to send one EDU if a remote user, etc). This + // does mean that if a user has many devices we may go over the desired + // limit, but its not a hard limit so that is fine. + if (currentSlice.length > maxDevicesPerRequest) { + // the current slice is filled up. Start inserting into the next slice + currentSlice = []; + mapSlices.push(currentSlice); + } + } + if (currentSlice.length === 0) { + mapSlices.pop(); + } + return mapSlices; + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {number} chainIndex current chain index + * + * @param {object} userDeviceMap + * mapping from userId to deviceInfo + * + * @param {object} payload fields to include in the encrypted payload + * + * @return {Promise} Promise which resolves once the key sharing + * for the given userDeviceMap is generated and has been sent. + */ + private encryptAndSendKeysToDevices( + session: OutboundSessionInfo, + chainIndex: number, + userDeviceMap: IOlmDevice[], + payload: IPayload, + ): Promise { + const contentMap = {}; + + const promises = []; + for (let i = 0; i < userDeviceMap.length; i++) { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + const val = userDeviceMap[i]; + const userId = val.userId; + const deviceInfo = val.deviceInfo; + const deviceId = deviceInfo.deviceId; + + if (!contentMap[userId]) { + contentMap[userId] = {}; + } + contentMap[userId][deviceId] = encryptedContent; + + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ), + ); + } + + return Promise.all(promises).then(() => { + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { + logger.log( + "No ciphertext for device " + + userId + ":" + deviceId + ": pruning", + ); + delete contentMap[userId][deviceId]; + } + } + // No devices left for that user? Strip that too. + if (Object.keys(contentMap[userId]).length === 0) { + logger.log("Pruned all devices for user " + userId); + delete contentMap[userId]; + } + } + + // Is there anything left? + if (Object.keys(contentMap).length === 0) { + logger.log("No users left to send to: aborting"); + return; + } + + return this.baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => { + // store that we successfully uploaded the keys of the current slice + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + session.markSharedWithDevice( + userId, deviceId, chainIndex, + ); + } + } + }); + }); + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {array} userDeviceMap list of blocked devices to notify + * + * @param {object} payload fields to include in the notification payload + * + * @return {Promise} Promise which resolves once the notifications + * for the given userDeviceMap is generated and has been sent. + */ + private async sendBlockedNotificationsToDevices( + session: OutboundSessionInfo, + userDeviceMap: IOlmDevice[], + payload: IPayload, + ): Promise { + const contentMap = {}; + + for (const val of userDeviceMap) { + const userId = val.userId; + const blockedInfo = val.deviceInfo; + const deviceInfo = blockedInfo.deviceInfo; + const deviceId = deviceInfo.deviceId; + + const message = Object.assign({}, payload); + message.code = blockedInfo.code; + message.reason = blockedInfo.reason; + if (message.code === "m.no_olm") { + delete message.room_id; + delete message.session_id; + } + + if (!contentMap[userId]) { + contentMap[userId] = {}; + } + contentMap[userId][deviceId] = message; + } + + await this.baseApis.sendToDevice("org.matrix.room_key.withheld", contentMap); + + // store that we successfully uploaded the keys of the current slice + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + session.markNotifiedBlockedDevice(userId, deviceId); + } + } + } + + /** + * Re-shares a megolm session key with devices if the key has already been + * sent to them. + * + * @param {string} senderKey The key of the originating device for the session + * @param {string} sessionId ID of the outbound session to share + * @param {string} userId ID of the user who owns the target device + * @param {module:crypto/deviceinfo} device The target device + */ + public async reshareKeyWithDevice( + senderKey: string, + sessionId: string, + userId: string, + device: DeviceInfo, + ): Promise { + const obSessionInfo = this.outboundSessions[sessionId]; + if (!obSessionInfo) { + logger.debug(`megolm session ${sessionId} not found: not re-sharing keys`); + return; + } + + // The chain index of the key we previously sent this device + if (obSessionInfo.sharedWithDevices[userId] === undefined) { + logger.debug(`megolm session ${sessionId} never shared with user ${userId}`); + return; + } + const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; + if (sentChainIndex === undefined) { + logger.debug( + "megolm session ID " + sessionId + " never shared with device " + + userId + ":" + device.deviceId, + ); + return; + } + + // get the key from the inbound session: the outbound one will already + // have been ratcheted to the next chain index. + const key = await this.olmDevice.getInboundGroupSessionKey( + this.roomId, senderKey, sessionId, sentChainIndex, + ); + + if (!key) { + logger.warn( + `No inbound session key found for megolm ${sessionId}: not re-sharing keys`, + ); + return; + } + + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { + [userId]: [device], + }, + ); + + const payload = { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false, + }, + }; + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + device, + payload, + ); + + await this.baseApis.sendToDevice("m.room.encrypted", { + [userId]: { + [device.deviceId]: encryptedContent, + }, + }); + logger.debug(`Re-shared key for megolm session ${sessionId} with ${userId}:${device.deviceId}`); + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} key the session key as returned by + * OlmDevice.getOutboundGroupSessionKey + * + * @param {object} payload the base to-device message payload for sharing keys + * + * @param {object} devicesByUser + * map from userid to list of devices + * + * @param {array} errorDevices + * array that will be populated with the devices that we can't get an + * olm session for + * + * @param {Number} [otkTimeout] The timeout in milliseconds when requesting + * one-time keys for establishing new olm sessions. + * + * @param {Array} [failedServers] An array to fill with remote servers that + * failed to respond to one-time-key requests. + */ + private async shareKeyWithDevices( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + payload: IPayload, + devicesByUser: Record, + errorDevices: IOlmDevice[], + otkTimeout: number, + failedServers?: string[], + ) { + logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`); + const devicemap = await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers, + logger.withPrefix(`[${this.roomId}]`), + ); + logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`); + + this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); + + logger.debug(`Sharing keys with Olm sessions in ${this.roomId}`); + await this.shareKeyWithOlmSessions(session, key, payload, devicemap); + logger.debug(`Shared keys with Olm sessions in ${this.roomId}`); + } + + private async shareKeyWithOlmSessions( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + payload: IPayload, + devicemap: Record>, + ): Promise { + const userDeviceMaps = this.splitDevices(devicemap); + + for (let i = 0; i < userDeviceMaps.length; i++) { + const taskDetail = + `megolm keys for ${session.sessionId} ` + + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`; + try { + logger.debug(`Sharing ${taskDetail}`); + await this.encryptAndSendKeysToDevices( + session, key.chain_index, userDeviceMaps[i], payload, + ); + logger.debug(`Shared ${taskDetail}`); + } catch (e) { + logger.error(`Failed to share ${taskDetail}`); + throw e; + } + } + } + + /** + * Notify devices that we weren't able to create olm sessions. + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} key + * + * @param {Array} failedDevices the devices that we were unable to + * create olm sessions for, as returned by shareKeyWithDevices + */ + private async notifyFailedOlmDevices( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + failedDevices: IOlmDevice[], + ): Promise { + logger.debug( + `Notifying ${failedDevices.length} devices we failed to ` + + `create Olm sessions in ${this.roomId}`, + ); + + // mark the devices that failed as "handled" because we don't want to try + // to claim a one-time-key for dead devices on every message. + for (const { userId, deviceInfo } of failedDevices) { + const deviceId = deviceInfo.deviceId; + + session.markSharedWithDevice( + userId, deviceId, key.chain_index, + ); + } + + const filteredFailedDevices = + await this.olmDevice.filterOutNotifiedErrorDevices( + failedDevices, + ); + logger.debug( + `Filtered down to ${filteredFailedDevices.length} error devices ` + + `in ${this.roomId}`, + ); + const blockedMap: Record> = {}; + for (const { userId, deviceInfo } of filteredFailedDevices) { + blockedMap[userId] = blockedMap[userId] || {}; + // we use a similar format to what + // olmlib.ensureOlmSessionsForDevices returns, so that + // we can use the same function to split + blockedMap[userId][deviceInfo.deviceId] = { + device: { + code: "m.no_olm", + reason: WITHHELD_MESSAGES["m.no_olm"], + deviceInfo, + }, + }; + } + + // send the notifications + await this.notifyBlockedDevices(session, blockedMap); + logger.debug( + `Notified ${filteredFailedDevices.length} devices we failed to ` + + `create Olm sessions in ${this.roomId}`, + ); + } + + /** + * Notify blocked devices that they have been blocked. + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} devicesByUser + * map from userid to device ID to blocked data + */ + private async notifyBlockedDevices( + session: OutboundSessionInfo, + devicesByUser: Record>, + ): Promise { + const payload: IPayload = { + room_id: this.roomId, + session_id: session.sessionId, + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + }; + + const userDeviceMaps = this.splitDevices(devicesByUser); + + for (let i = 0; i < userDeviceMaps.length; i++) { + try { + await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload); + logger.log(`Completed blacklist notification for ${session.sessionId} ` + + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`); + } catch (e) { + logger.log(`blacklist notification for ${session.sessionId} in ` + + `${this.roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`); + + throw e; + } + } + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param {module:models/room} room the room the event is in + */ + public prepareToEncrypt(room: Room): void { + if (this.encryptionPreparation) { + // We're already preparing something, so don't do anything else. + // FIXME: check if we need to restart + // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) + const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; + logger.debug( + `Already started preparing to encrypt for ${this.roomId} ` + + `${elapsedTime} ms ago, skipping`, + ); + return; + } + + logger.debug(`Preparing to encrypt events for ${this.roomId}`); + + this.encryptionPreparationMetadata = { + startTime: Date.now(), + }; + this.encryptionPreparation = (async () => { + try { + logger.debug(`Getting devices in ${this.roomId}`); + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + + if (this.crypto.getGlobalErrorOnUnknownDevices()) { + // Drop unknown devices for now. When the message gets sent, we'll + // throw an error, but we'll still be prepared to send to the known + // devices. + this.removeUnknownDevices(devicesInRoom); + } + + logger.debug(`Ensuring outbound session in ${this.roomId}`); + await this.ensureOutboundSession(room, devicesInRoom, blocked, true); + + logger.debug(`Ready to encrypt events for ${this.roomId}`); + } catch (e) { + logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e); + } finally { + delete this.encryptionPreparationMetadata; + delete this.encryptionPreparation; + } + })(); + } + + /** + * @inheritdoc + * + * @param {module:models/room} room + * @param {string} eventType + * @param {object} content plaintext event content + * + * @return {Promise} Promise which resolves to the new event body + */ + public async encryptMessage(room: Room, eventType: string, content: object): Promise { + logger.log(`Starting to encrypt event for ${this.roomId}`); + + if (this.encryptionPreparation) { + // If we started sending keys, wait for it to be done. + // FIXME: check if we need to cancel + // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) + try { + await this.encryptionPreparation; + } catch (e) { + // ignore any errors -- if the preparation failed, we'll just + // restart everything here + } + } + + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + + // check if any of these devices are not yet known to the user. + // if so, warn the user so they can verify or ignore. + if (this.crypto.getGlobalErrorOnUnknownDevices()) { + this.checkForUnknownDevices(devicesInRoom); + } + + const session = await this.ensureOutboundSession(room, devicesInRoom, blocked); + const payloadJson = { + room_id: this.roomId, + type: eventType, + content: content, + }; + + const ciphertext = this.olmDevice.encryptGroupMessage( + session.sessionId, JSON.stringify(payloadJson), + ); + const encryptedContent = { + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: ciphertext, + session_id: session.sessionId, + // Include our device ID so that recipients can send us a + // m.new_device message if they don't have our session key. + // XXX: Do we still need this now that m.new_device messages + // no longer exist since #483? + device_id: this.deviceId, + }; + + session.useCount++; + return encryptedContent; + } + + /** + * Forces the current outbound group session to be discarded such + * that another one will be created next time an event is sent. + * + * This should not normally be necessary. + */ + public forceDiscardSession(): void { + this.setupPromise = this.setupPromise.then(() => null); + } + + /** + * Checks the devices we're about to send to and see if any are entirely + * unknown to the user. If so, warn the user, and mark them as known to + * give the user a chance to go verify them before re-sending this message. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + */ + private checkForUnknownDevices(devicesInRoom: DeviceInfoMap): void { + const unknownDevices = {}; + + Object.keys(devicesInRoom).forEach((userId)=>{ + Object.keys(devicesInRoom[userId]).forEach((deviceId)=>{ + const device = devicesInRoom[userId][deviceId]; + if (device.isUnverified() && !device.isKnown()) { + if (!unknownDevices[userId]) { + unknownDevices[userId] = {}; + } + unknownDevices[userId][deviceId] = device; + } + }); + }); + + if (Object.keys(unknownDevices).length) { + // it'd be kind to pass unknownDevices up to the user in this error + throw new UnknownDeviceError( + "This room contains unknown devices which have not been verified. " + + "We strongly recommend you verify them before continuing.", unknownDevices); + } + } + + /** + * Remove unknown devices from a set of devices. The devicesInRoom parameter + * will be modified. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + */ + private removeUnknownDevices(devicesInRoom: DeviceInfoMap): void { + for (const [userId, userDevices] of Object.entries(devicesInRoom)) { + for (const [deviceId, device] of Object.entries(userDevices)) { + if (device.isUnverified() && !device.isKnown()) { + delete userDevices[deviceId]; + } + } + + if (Object.keys(userDevices).length === 0) { + delete devicesInRoom[userId]; + } + } + } + + /** + * Get the list of unblocked devices for all users in the room + * + * @param {module:models/room} room + * + * @return {Promise} Promise which resolves to an array whose + * first element is a map from userId to deviceId to deviceInfo indicating + * the devices that messages should be encrypted to, and whose second + * element is a map from userId to deviceId to data indicating the devices + * that are in the room but that have been blocked + */ + private async getDevicesInRoom(room: Room): Promise<[DeviceInfoMap, IBlockedMap]> { + const members = await room.getEncryptionTargetMembers(); + const roomMembers = members.map(function(u) { + return u.userId; + }); + + // The global value is treated as a default for when rooms don't specify a value. + let isBlacklisting = this.crypto.getGlobalBlacklistUnverifiedDevices(); + if (typeof room.getBlacklistUnverifiedDevices() === 'boolean') { + isBlacklisting = room.getBlacklistUnverifiedDevices(); + } + + // We are happy to use a cached version here: we assume that if we already + // have a list of the user's devices, then we already share an e2e room + // with them, which means that they will have announced any new devices via + // device_lists in their /sync response. This cache should then be maintained + // using all the device_lists changes and left fields. + // See https://github.com/vector-im/element-web/issues/2305 for details. + const devices = await this.crypto.downloadKeys(roomMembers, false); + const blocked: IBlockedMap = {}; + // remove any blocked devices + for (const userId in devices) { + if (!devices.hasOwnProperty(userId)) { + continue; + } + + const userDevices = devices[userId]; + for (const deviceId in userDevices) { + if (!userDevices.hasOwnProperty(deviceId)) { + continue; + } + + const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId); + + if (userDevices[deviceId].isBlocked() || + (!deviceTrust.isVerified() && isBlacklisting) + ) { + if (!blocked[userId]) { + blocked[userId] = {}; + } + const isBlocked = userDevices[deviceId].isBlocked(); + blocked[userId][deviceId] = { + code: isBlocked ? "m.blacklisted" : "m.unverified", + reason: WITHHELD_MESSAGES[isBlocked ? "m.blacklisted" : "m.unverified"], + deviceInfo: userDevices[deviceId], + }; + delete userDevices[deviceId]; + } + } + } + + return [devices, blocked]; + } +} + +/** + * Megolm decryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/DecryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/DecryptionAlgorithm} + */ +class MegolmDecryption extends DecryptionAlgorithm { + // events which we couldn't decrypt due to unknown sessions / indexes: map from + // senderKey|sessionId to Set of MatrixEvents + private pendingEvents: Record>> = {}; + + // this gets stubbed out by the unit tests. + private olmlib = olmlib; + + /** + * @inheritdoc + * + * @param {MatrixEvent} event + * + * returns a promise which resolves to a + * {@link module:crypto~EventDecryptionResult} once we have finished + * decrypting, or rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise { + const content = event.getWireContent(); + + if (!content.sender_key || !content.session_id || + !content.ciphertext + ) { + throw new DecryptionError( + "MEGOLM_MISSING_FIELDS", + "Missing fields in input", + ); + } + + // we add the event to the pending list *before* we start decryption. + // + // then, if the key turns up while decryption is in progress (and + // decryption fails), we will schedule a retry. + // (fixes https://github.com/vector-im/element-web/issues/5001) + this.addEventToPendingList(event); + + let res; + try { + res = await this.olmDevice.decryptGroupMessage( + event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, + event.getId(), event.getTs(), + ); + } catch (e) { + if (e.name === "DecryptionError") { + // re-throw decryption errors as-is + throw e; + } + + let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; + + if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { + this.requestKeysForEvent(event); + + errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX'; + } + + throw new DecryptionError( + errorCode, + e ? e.toString() : "Unknown Error: Error is undefined", { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + if (res === null) { + // We've got a message for a session we don't have. + // + // (XXX: We might actually have received this key since we started + // decrypting, in which case we'll have scheduled a retry, and this + // request will be redundant. We could probably check to see if the + // event is still in the pending list; if not, a retry will have been + // scheduled, so we needn't send out the request here.) + this.requestKeysForEvent(event); + + // See if there was a problem with the olm session at the time the + // event was sent. Use a fuzz factor of 2 minutes. + const problem = await this.olmDevice.sessionMayHaveProblems( + content.sender_key, event.getTs() - 120000, + ); + if (problem) { + let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] + || PROBLEM_DESCRIPTIONS.unknown; + if (problem.fixed) { + problemDescription += + " Trying to create a new secure channel and re-requesting the keys."; + } + throw new DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + problemDescription, + { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + throw new DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + "The sender's device has not sent us the keys for this message.", + { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + // success. We can remove the event from the pending list, if that hasn't + // already happened. + this.removeEventFromPendingList(event); + + const payload = JSON.parse(res.result); + + // belt-and-braces check that the room id matches that indicated by the HS + // (this is somewhat redundant, since the megolm session is scoped to the + // room, so neither the sender nor a MITM can lie about the room_id). + if (payload.room_id !== event.getRoomId()) { + throw new DecryptionError( + "MEGOLM_BAD_ROOM", + "Message intended for room " + payload.room_id, + ); + } + + return { + clearEvent: payload, + senderCurve25519Key: res.senderKey, + claimedEd25519Key: res.keysClaimed.ed25519, + forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, + untrusted: res.untrusted, + }; + } + + private requestKeysForEvent(event: MatrixEvent): void { + const wireContent = event.getWireContent(); + + const recipients = event.getKeyRequestRecipients(this.userId); + + this.crypto.requestRoomKey({ + room_id: event.getRoomId(), + algorithm: wireContent.algorithm, + sender_key: wireContent.sender_key, + session_id: wireContent.session_id, + }, recipients); + } + + /** + * Add an event to the list of those awaiting their session keys. + * + * @private + * + * @param {module:models/event.MatrixEvent} event + */ + private addEventToPendingList(event: MatrixEvent): void { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + if (!this.pendingEvents[senderKey]) { + this.pendingEvents[senderKey] = new Map(); + } + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents.has(sessionId)) { + senderPendingEvents.set(sessionId, new Set()); + } + senderPendingEvents.get(sessionId).add(event); + } + + /** + * Remove an event from the list of those awaiting their session keys. + * + * @private + * + * @param {module:models/event.MatrixEvent} event + */ + private removeEventFromPendingList(event: MatrixEvent): void { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + const senderPendingEvents = this.pendingEvents[senderKey]; + const pendingEvents = senderPendingEvents && senderPendingEvents.get(sessionId); + if (!pendingEvents) { + return; + } + + pendingEvents.delete(event); + if (pendingEvents.size === 0) { + senderPendingEvents.delete(senderKey); + } + if (senderPendingEvents.size === 0) { + delete this.pendingEvents[senderKey]; + } + } + + /** + * @inheritdoc + * + * @param {module:models/event.MatrixEvent} event key event + */ + public onRoomKeyEvent(event: MatrixEvent): void { + const content = event.getContent(); + const sessionId = content.session_id; + let senderKey = event.getSenderKey(); + let forwardingKeyChain = []; + let exportFormat = false; + let keysClaimed; + + if (!content.room_id || + !sessionId || + !content.session_key + ) { + logger.error("key event is missing fields"); + return; + } + + if (!senderKey) { + logger.error("key event has no sender key (not encrypted?)"); + return; + } + + if (event.getType() == "m.forwarded_room_key") { + exportFormat = true; + forwardingKeyChain = content.forwarding_curve25519_key_chain; + if (!Array.isArray(forwardingKeyChain)) { + forwardingKeyChain = []; + } + + // copy content before we modify it + forwardingKeyChain = forwardingKeyChain.slice(); + forwardingKeyChain.push(senderKey); + + senderKey = content.sender_key; + if (!senderKey) { + logger.error("forwarded_room_key event is missing sender_key field"); + return; + } + + const ed25519Key = content.sender_claimed_ed25519_key; + if (!ed25519Key) { + logger.error( + `forwarded_room_key_event is missing sender_claimed_ed25519_key field`, + ); + return; + } + + keysClaimed = { + ed25519: ed25519Key, + }; + } else { + keysClaimed = event.getKeysClaimed(); + } + + const extraSessionData: any = {}; + if (content["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + return this.olmDevice.addInboundGroupSession( + content.room_id, senderKey, forwardingKeyChain, sessionId, + content.session_key, keysClaimed, + exportFormat, extraSessionData, + ).then(() => { + // have another go at decrypting events sent with this session. + this.retryDecryption(senderKey, sessionId) + .then((success) => { + // cancel any outstanding room key requests for this session. + // Only do this if we managed to decrypt every message in the + // session, because if we didn't, we leave the other key + // requests in the hopes that someone sends us a key that + // includes an earlier index. + if (success) { + this.crypto.cancelRoomKeyRequest({ + algorithm: content.algorithm, + room_id: content.room_id, + session_id: content.session_id, + sender_key: senderKey, + }); + } + }); + }).then(() => { + // don't wait for the keys to be backed up for the server + this.crypto.backupManager.backupGroupSession(senderKey, content.session_id); + }).catch((e) => { + logger.error(`Error handling m.room_key_event: ${e}`); + }); + } + + /** + * @inheritdoc + * + * @param {module:models/event.MatrixEvent} event key event + */ + public async onRoomKeyWithheldEvent(event: MatrixEvent): Promise { + const content = event.getContent(); + const senderKey = content.sender_key; + + if (content.code === "m.no_olm") { + const sender = event.getSender(); + logger.warn( + `${sender}:${senderKey} was unable to establish an olm session with us`, + ); + // if the sender says that they haven't been able to establish an olm + // session, let's proactively establish one + + // Note: after we record that the olm session has had a problem, we + // trigger retrying decryption for all the messages from the sender's + // key, so that we can update the error message to indicate the olm + // session problem. + + if (await this.olmDevice.getSessionIdForDevice(senderKey)) { + // a session has already been established, so we don't need to + // create a new one. + logger.debug("New session already created. Not creating a new one."); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + this.retryDecryptionFromSender(senderKey); + return; + } + let device = this.crypto.deviceList.getDeviceByIdentityKey( + content.algorithm, senderKey, + ); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.crypto.downloadKeys([sender], false); + device = this.crypto.deviceList.getDeviceByIdentityKey( + content.algorithm, senderKey, + ); + if (!device) { + logger.info( + "Couldn't find device for identity key " + senderKey + + ": not establishing session", + ); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false); + this.retryDecryptionFromSender(senderKey); + return; + } + } + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { [sender]: [device] }, false, + ); + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + sender, + device, + { type: "m.dummy" }, + ); + + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + this.retryDecryptionFromSender(senderKey); + + await this.baseApis.sendToDevice("m.room.encrypted", { + [sender]: { + [device.deviceId]: encryptedContent, + }, + }); + } else { + await this.olmDevice.addInboundGroupSessionWithheld( + content.room_id, senderKey, content.session_id, content.code, + content.reason, + ); + } + } + + /** + * @inheritdoc + */ + public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { + const body = keyRequest.requestBody; + + return this.olmDevice.hasInboundSessionKeys( + body.room_id, + body.sender_key, + body.session_id, + // TODO: ratchet index + ); + } + + /** + * @inheritdoc + */ + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + const deviceInfo = this.crypto.getStoredDevice(userId, deviceId); + const body = keyRequest.requestBody; + + this.olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { + [userId]: [deviceInfo], + }, + ).then((devicemap) => { + const olmSessionResult = devicemap[userId][deviceId]; + if (!olmSessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + // + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + return null; + } + + logger.log( + "sharing keys for session " + body.sender_key + "|" + + body.session_id + " with device " + + userId + ":" + deviceId, + ); + + return this.buildKeyForwardingMessage( + body.room_id, body.sender_key, body.session_id, + ); + }).then((payload) => { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + + return this.olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ).then(() => { + const contentMap = { + [userId]: { + [deviceId]: encryptedContent, + }, + }; + + // TODO: retries + return this.baseApis.sendToDevice("m.room.encrypted", contentMap); + }); + }); + } + + private async buildKeyForwardingMessage( + roomId: string, + senderKey: string, + sessionId: string, + ): Promise { + const key = await this.olmDevice.getInboundGroupSessionKey(roomId, senderKey, sessionId); + + return { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": roomId, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false, + }, + }; + } + + /** + * @inheritdoc + * + * @param {module:crypto/OlmDevice.MegolmSessionData} session + * @param {object} [opts={}] options for the import + * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted + * @param {string} [opts.source] where the key came from + */ + public importRoomKey(session: IMegolmSessionData, opts: any = {}): Promise { + const extraSessionData: any = {}; + if (opts.untrusted) { + extraSessionData.untrusted = true; + } + if (session["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + return this.olmDevice.addInboundGroupSession( + session.room_id, + session.sender_key, + session.forwarding_curve25519_key_chain, + session.session_id, + session.session_key, + session.sender_claimed_keys, + true, + extraSessionData, + ).then(() => { + if (opts.source !== "backup") { + // don't wait for it to complete + 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. + logger.log("Failed to back up megolm session", e); + }); + } + // have another go at decrypting events sent with this session. + this.retryDecryption(session.sender_key, session.session_id); + }); + } + + /** + * Have another go at decrypting events after we receive a key. Resolves once + * decryption has been re-attempted on all events. + * + * @private + * @param {String} senderKey + * @param {String} sessionId + * + * @return {Boolean} whether all messages were successfully decrypted + */ + private async retryDecryption(senderKey: string, sessionId: string): Promise { + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents) { + return true; + } + + const pending = senderPendingEvents.get(sessionId); + if (!pending) { + return true; + } + + logger.debug("Retrying decryption on events", [...pending]); + + await Promise.all([...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto, { isRetry: true }); + } catch (e) { + // don't die if something goes wrong + } + })); + + // If decrypted successfully, they'll have been removed from pendingEvents + return !((this.pendingEvents[senderKey] || {})[sessionId]); + } + + public async retryDecryptionFromSender(senderKey: string): Promise { + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents) { + return true; + } + + delete this.pendingEvents[senderKey]; + + await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { + await Promise.all([...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto); + } catch (e) { + // don't die if something goes wrong + } + })); + })); + + return !this.pendingEvents[senderKey]; + } + + public async sendSharedHistoryInboundSessions(devicesByUser: Record): Promise { + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); + + logger.log("sendSharedHistoryInboundSessions to users", Object.keys(devicesByUser)); + + const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId); + logger.log("shared-history sessions", sharedHistorySessions); + for (const [senderKey, sessionId] of sharedHistorySessions) { + const payload = await this.buildKeyForwardingMessage(this.roomId, senderKey, sessionId); + + const promises = []; + const contentMap = {}; + for (const [userId, devices] of Object.entries(devicesByUser)) { + contentMap[userId] = {}; + for (const deviceInfo of devices) { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + contentMap[userId][deviceInfo.deviceId] = encryptedContent; + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ), + ); + } + } + await Promise.all(promises); + + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { + logger.log( + "No ciphertext for device " + + userId + ":" + deviceId + ": pruning", + ); + delete contentMap[userId][deviceId]; + } + } + // No devices left for that user? Strip that too. + if (Object.keys(contentMap[userId]).length === 0) { + logger.log("Pruned all devices for user " + userId); + delete contentMap[userId]; + } + } + + // Is there anything left? + if (Object.keys(contentMap).length === 0) { + logger.log("No users left to send to: aborting"); + return; + } + + await this.baseApis.sendToDevice("m.room.encrypted", contentMap); + } + } +} + +const PROBLEM_DESCRIPTIONS = { + no_olm: "The sender was unable to establish a secure channel.", + unknown: "The secure channel with the sender was corrupted.", +}; + +registerAlgorithm(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption); diff --git a/src/crypto/algorithms/olm.js b/src/crypto/algorithms/olm.js deleted file mode 100644 index 74444b75a..000000000 --- a/src/crypto/algorithms/olm.js +++ /dev/null @@ -1,361 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd - -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. -*/ - -/** - * Defines m.olm encryption/decryption - * - * @module crypto/algorithms/olm - */ - -import { logger } from '../../logger'; -import * as utils from "../../utils"; -import { polyfillSuper } from "../../utils"; -import * as olmlib from "../olmlib"; -import { DeviceInfo } from "../deviceinfo"; -import { - DecryptionAlgorithm, - DecryptionError, - EncryptionAlgorithm, - registerAlgorithm, -} from "./base"; - -const DeviceVerification = DeviceInfo.DeviceVerification; - -/** - * Olm encryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/EncryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/EncryptionAlgorithm} - */ -function OlmEncryption(params) { - polyfillSuper(this, EncryptionAlgorithm, params); - this._sessionPrepared = false; - this._prepPromise = null; -} -utils.inherits(OlmEncryption, EncryptionAlgorithm); - -/** - * @private - - * @param {string[]} roomMembers list of currently-joined users in the room - * @return {Promise} Promise which resolves when setup is complete - */ -OlmEncryption.prototype._ensureSession = function(roomMembers) { - if (this._prepPromise) { - // prep already in progress - return this._prepPromise; - } - - if (this._sessionPrepared) { - // prep already done - return Promise.resolve(); - } - - const self = this; - this._prepPromise = self._crypto.downloadKeys(roomMembers).then(function(res) { - return self._crypto.ensureOlmSessionsForUsers(roomMembers); - }).then(function() { - self._sessionPrepared = true; - }).finally(function() { - self._prepPromise = null; - }); - return this._prepPromise; -}; - -/** - * @inheritdoc - * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content plaintext event content - * - * @return {Promise} Promise which resolves to the new event body - */ -OlmEncryption.prototype.encryptMessage = async function(room, eventType, content) { - // pick the list of recipients based on the membership list. - // - // TODO: there is a race condition here! What if a new user turns up - // just as you are sending a secret message? - - const members = await room.getEncryptionTargetMembers(); - - const users = members.map(function(u) { - return u.userId; - }); - - const self = this; - await this._ensureSession(users); - - const payloadFields = { - room_id: room.roomId, - type: eventType, - content: content, - }; - - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: self._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - - const promises = []; - - for (let i = 0; i < users.length; ++i) { - const userId = users[i]; - const devices = self._crypto.getStoredDevicesForUser(userId); - - for (let j = 0; j < devices.length; ++j) { - const deviceInfo = devices[j]; - const key = deviceInfo.getIdentityKey(); - if (key == self._olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - if (deviceInfo.verified == DeviceVerification.BLOCKED) { - // don't bother setting up sessions with blocked users - continue; - } - - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - self._userId, self._deviceId, self._olmDevice, - userId, deviceInfo, payloadFields, - ), - ); - } - } - - return await Promise.all(promises).then(() => encryptedContent); -}; - -/** - * Olm decryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/DecryptionAlgorithm} - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/DecryptionAlgorithm} - */ -function OlmDecryption(params) { - polyfillSuper(this, DecryptionAlgorithm, params); -} -utils.inherits(OlmDecryption, DecryptionAlgorithm); - -/** - * @inheritdoc - * - * @param {MatrixEvent} event - * - * returns a promise which resolves to a - * {@link module:crypto~EventDecryptionResult} once we have finished - * decrypting. Rejects with an `algorithms.DecryptionError` if there is a - * problem decrypting the event. - */ -OlmDecryption.prototype.decryptEvent = async function(event) { - const content = event.getWireContent(); - const deviceKey = content.sender_key; - const ciphertext = content.ciphertext; - - if (!ciphertext) { - throw new DecryptionError( - "OLM_MISSING_CIPHERTEXT", - "Missing ciphertext", - ); - } - - if (!(this._olmDevice.deviceCurve25519Key in ciphertext)) { - throw new DecryptionError( - "OLM_NOT_INCLUDED_IN_RECIPIENTS", - "Not included in recipients", - ); - } - const message = ciphertext[this._olmDevice.deviceCurve25519Key]; - let payloadString; - - try { - payloadString = await this._decryptMessage(deviceKey, message); - } catch (e) { - throw new DecryptionError( - "OLM_BAD_ENCRYPTED_MESSAGE", - "Bad Encrypted Message", { - sender: deviceKey, - err: e, - }, - ); - } - - const payload = JSON.parse(payloadString); - - // check that we were the intended recipient, to avoid unknown-key attack - // https://github.com/vector-im/vector-web/issues/2483 - if (payload.recipient != this._userId) { - throw new DecryptionError( - "OLM_BAD_RECIPIENT", - "Message was intented for " + payload.recipient, - ); - } - - if (payload.recipient_keys.ed25519 != this._olmDevice.deviceEd25519Key) { - throw new DecryptionError( - "OLM_BAD_RECIPIENT_KEY", - "Message not intended for this device", { - intended: payload.recipient_keys.ed25519, - our_key: this._olmDevice.deviceEd25519Key, - }, - ); - } - - // check that the original sender matches what the homeserver told us, to - // avoid people masquerading as others. - // (this check is also provided via the sender's embedded ed25519 key, - // which is checked elsewhere). - if (payload.sender != event.getSender()) { - throw new DecryptionError( - "OLM_FORWARDED_MESSAGE", - "Message forwarded from " + payload.sender, { - reported_sender: event.getSender(), - }, - ); - } - - // Olm events intended for a room have a room_id. - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - "OLM_BAD_ROOM", - "Message intended for room " + payload.room_id, { - reported_room: event.room_id, - }, - ); - } - - const claimedKeys = payload.keys || {}; - - return { - clearEvent: payload, - senderCurve25519Key: deviceKey, - claimedEd25519Key: claimedKeys.ed25519 || null, - }; -}; - -/** - * Attempt to decrypt an Olm message - * - * @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender - * @param {object} message message object, with 'type' and 'body' fields - * - * @return {string} payload, if decrypted successfully. - */ -OlmDecryption.prototype._decryptMessage = async function( - theirDeviceIdentityKey, message, -) { - // This is a wrapper that serialises decryptions of prekey messages, because - // otherwise we race between deciding we have no active sessions for the message - // and creating a new one, which we can only do once because it removes the OTK. - if (message.type !== 0) { - // not a prekey message: we can safely just try & decrypt it - return this._reallyDecryptMessage(theirDeviceIdentityKey, message); - } else { - const myPromise = this._olmDevice._olmPrekeyPromise.then(() => { - return this._reallyDecryptMessage(theirDeviceIdentityKey, message); - }); - // we want the error, but don't propagate it to the next decryption - this._olmDevice._olmPrekeyPromise = myPromise.catch(() => {}); - return await myPromise; - } -}; - -OlmDecryption.prototype._reallyDecryptMessage = async function( - theirDeviceIdentityKey, message, -) { - const sessionIds = await this._olmDevice.getSessionIdsForDevice( - theirDeviceIdentityKey, - ); - - // try each session in turn. - const decryptionErrors = {}; - for (let i = 0; i < sessionIds.length; i++) { - const sessionId = sessionIds[i]; - try { - const payload = await this._olmDevice.decryptMessage( - theirDeviceIdentityKey, sessionId, message.type, message.body, - ); - logger.log( - "Decrypted Olm message from " + theirDeviceIdentityKey + - " with session " + sessionId, - ); - return payload; - } catch (e) { - const foundSession = await this._olmDevice.matchesSession( - theirDeviceIdentityKey, sessionId, message.type, message.body, - ); - - if (foundSession) { - // decryption failed, but it was a prekey message matching this - // session, so it should have worked. - throw new Error( - "Error decrypting prekey message with existing session id " + - sessionId + ": " + e.message, - ); - } - - // otherwise it's probably a message for another session; carry on, but - // keep a record of the error - decryptionErrors[sessionId] = e.message; - } - } - - if (message.type !== 0) { - // not a prekey message, so it should have matched an existing session, but it - // didn't work. - - if (sessionIds.length === 0) { - throw new Error("No existing sessions"); - } - - throw new Error( - "Error decrypting non-prekey message with existing sessions: " + - JSON.stringify(decryptionErrors), - ); - } - - // prekey message which doesn't match any existing sessions: make a new - // session. - - let res; - try { - res = await this._olmDevice.createInboundSession( - theirDeviceIdentityKey, message.type, message.body, - ); - } catch (e) { - decryptionErrors["(new)"] = e.message; - throw new Error( - "Error decrypting prekey message: " + - JSON.stringify(decryptionErrors), - ); - } - - logger.log( - "created new inbound Olm session ID " + - res.session_id + " with " + theirDeviceIdentityKey, - ); - return res.payload; -}; - -registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts new file mode 100644 index 000000000..d45365ba9 --- /dev/null +++ b/src/crypto/algorithms/olm.ts @@ -0,0 +1,355 @@ +/* +Copyright 2016 - 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. +*/ + +/** + * Defines m.olm encryption/decryption + * + * @module crypto/algorithms/olm + */ + +import { logger } from '../../logger'; +import * as olmlib from "../olmlib"; +import { DeviceInfo } from "../deviceinfo"; +import { + DecryptionAlgorithm, + DecryptionError, + EncryptionAlgorithm, + registerAlgorithm, +} from "./base"; +import { Room } from '../../models/room'; +import { MatrixEvent } from "../.."; +import { IEventDecryptionResult } from "../index"; + +const DeviceVerification = DeviceInfo.DeviceVerification; + +interface IMessage { + type: number | string; + body: string; +} + +/** + * Olm encryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/EncryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/EncryptionAlgorithm} + */ +class OlmEncryption extends EncryptionAlgorithm { + private sessionPrepared = false; + private prepPromise: Promise = null; + + /** + * @private + + * @param {string[]} roomMembers list of currently-joined users in the room + * @return {Promise} Promise which resolves when setup is complete + */ + private ensureSession(roomMembers: string[]): Promise { + if (this.prepPromise) { + // prep already in progress + return this.prepPromise; + } + + if (this.sessionPrepared) { + // prep already done + return Promise.resolve(); + } + + this.prepPromise = this.crypto.downloadKeys(roomMembers).then((res) => { + return this.crypto.ensureOlmSessionsForUsers(roomMembers); + }).then(() => { + this.sessionPrepared = true; + }).finally(() => { + this.prepPromise = null; + }); + + return this.prepPromise; + } + + /** + * @inheritdoc + * + * @param {module:models/room} room + * @param {string} eventType + * @param {object} content plaintext event content + * + * @return {Promise} Promise which resolves to the new event body + */ + public async encryptMessage(room: Room, eventType: string, content: object): Promise { + // pick the list of recipients based on the membership list. + // + // TODO: there is a race condition here! What if a new user turns up + // just as you are sending a secret message? + + const members = await room.getEncryptionTargetMembers(); + + const users = members.map(function(u) { + return u.userId; + }); + + await this.ensureSession(users); + + const payloadFields = { + room_id: room.roomId, + type: eventType, + content: content, + }; + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + + const promises = []; + + for (let i = 0; i < users.length; ++i) { + const userId = users[i]; + const devices = this.crypto.getStoredDevicesForUser(userId); + + for (let j = 0; j < devices.length; ++j) { + const deviceInfo = devices[j]; + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + if (deviceInfo.verified == DeviceVerification.BLOCKED) { + // don't bother setting up sessions with blocked users + continue; + } + + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, this.deviceId, this.olmDevice, + userId, deviceInfo, payloadFields, + ), + ); + } + } + + return await Promise.all(promises).then(() => encryptedContent); + } +} + +/** + * Olm decryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/DecryptionAlgorithm} + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/DecryptionAlgorithm} + */ +class OlmDecryption extends DecryptionAlgorithm { + /** + * @inheritdoc + * + * @param {MatrixEvent} event + * + * returns a promise which resolves to a + * {@link module:crypto~EventDecryptionResult} once we have finished + * decrypting. Rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise { + const content = event.getWireContent(); + const deviceKey = content.sender_key; + const ciphertext = content.ciphertext; + + if (!ciphertext) { + throw new DecryptionError( + "OLM_MISSING_CIPHERTEXT", + "Missing ciphertext", + ); + } + + if (!(this.olmDevice.deviceCurve25519Key in ciphertext)) { + throw new DecryptionError( + "OLM_NOT_INCLUDED_IN_RECIPIENTS", + "Not included in recipients", + ); + } + const message = ciphertext[this.olmDevice.deviceCurve25519Key]; + let payloadString; + + try { + payloadString = await this.decryptMessage(deviceKey, message); + } catch (e) { + throw new DecryptionError( + "OLM_BAD_ENCRYPTED_MESSAGE", + "Bad Encrypted Message", { + sender: deviceKey, + err: e, + }, + ); + } + + const payload = JSON.parse(payloadString); + + // check that we were the intended recipient, to avoid unknown-key attack + // https://github.com/vector-im/vector-web/issues/2483 + if (payload.recipient != this.userId) { + throw new DecryptionError( + "OLM_BAD_RECIPIENT", + "Message was intented for " + payload.recipient, + ); + } + + if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) { + throw new DecryptionError( + "OLM_BAD_RECIPIENT_KEY", + "Message not intended for this device", { + intended: payload.recipient_keys.ed25519, + our_key: this.olmDevice.deviceEd25519Key, + }, + ); + } + + // check that the original sender matches what the homeserver told us, to + // avoid people masquerading as others. + // (this check is also provided via the sender's embedded ed25519 key, + // which is checked elsewhere). + if (payload.sender != event.getSender()) { + throw new DecryptionError( + "OLM_FORWARDED_MESSAGE", + "Message forwarded from " + payload.sender, { + reported_sender: event.getSender(), + }, + ); + } + + // Olm events intended for a room have a room_id. + if (payload.room_id !== event.getRoomId()) { + throw new DecryptionError( + "OLM_BAD_ROOM", + "Message intended for room " + payload.room_id, { + reported_room: event.getRoomId(), + }, + ); + } + + const claimedKeys = payload.keys || {}; + + return { + clearEvent: payload, + senderCurve25519Key: deviceKey, + claimedEd25519Key: claimedKeys.ed25519 || null, + }; + } + + /** + * Attempt to decrypt an Olm message + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender + * @param {object} message message object, with 'type' and 'body' fields + * + * @return {string} payload, if decrypted successfully. + */ + private async decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { + // This is a wrapper that serialises decryptions of prekey messages, because + // otherwise we race between deciding we have no active sessions for the message + // and creating a new one, which we can only do once because it removes the OTK. + if (message.type !== 0) { + // not a prekey message: we can safely just try & decrypt it + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + } else { + const myPromise = this.olmDevice._olmPrekeyPromise.then(() => { + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + }); + // we want the error, but don't propagate it to the next decryption + this.olmDevice._olmPrekeyPromise = myPromise.catch(() => {}); + return await myPromise; + } + } + + private async reallyDecryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { + const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey); + + // try each session in turn. + const decryptionErrors = {}; + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i]; + try { + const payload = await this.olmDevice.decryptMessage( + theirDeviceIdentityKey, sessionId, message.type, message.body, + ); + logger.log( + "Decrypted Olm message from " + theirDeviceIdentityKey + + " with session " + sessionId, + ); + return payload; + } catch (e) { + const foundSession = await this.olmDevice.matchesSession( + theirDeviceIdentityKey, sessionId, message.type, message.body, + ); + + if (foundSession) { + // decryption failed, but it was a prekey message matching this + // session, so it should have worked. + throw new Error( + "Error decrypting prekey message with existing session id " + + sessionId + ": " + e.message, + ); + } + + // otherwise it's probably a message for another session; carry on, but + // keep a record of the error + decryptionErrors[sessionId] = e.message; + } + } + + if (message.type !== 0) { + // not a prekey message, so it should have matched an existing session, but it + // didn't work. + + if (sessionIds.length === 0) { + throw new Error("No existing sessions"); + } + + throw new Error( + "Error decrypting non-prekey message with existing sessions: " + + JSON.stringify(decryptionErrors), + ); + } + + // prekey message which doesn't match any existing sessions: make a new + // session. + + let res; + try { + res = await this.olmDevice.createInboundSession( + theirDeviceIdentityKey, message.type, message.body, + ); + } catch (e) { + decryptionErrors["(new)"] = e.message; + throw new Error( + "Error decrypting prekey message: " + + JSON.stringify(decryptionErrors), + ); + } + + logger.log( + "created new inbound Olm session ID " + + res.session_id + " with " + theirDeviceIdentityKey, + ); + return res.payload; + } +} + +registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 132821219..b2b0c620e 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -28,7 +28,7 @@ import { ReEmitter } from '../ReEmitter'; import { logger } from '../logger'; import { OlmDevice } from "./OlmDevice"; import * as olmlib from "./olmlib"; -import { DeviceList } from "./DeviceList"; +import {DeviceInfoMap, DeviceList } from "./DeviceList"; import { DeviceInfo, IDevice } from "./deviceinfo"; import * as algorithms from "./algorithms"; import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from './CrossSigning'; @@ -1923,10 +1923,7 @@ export class Crypto extends EventEmitter { * @return {Promise} A promise which resolves to a map userId->deviceId->{@link * module:crypto/deviceinfo|DeviceInfo}. */ - public downloadKeys( - userIds: string[], - forceDownload?: boolean, - ): Promise>> { + public downloadKeys(userIds: string[], forceDownload?: boolean): Promise { return this.deviceList.downloadKeys(userIds, forceDownload); } diff --git a/src/models/event.ts b/src/models/event.ts index edcee4ad5..490988787 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -131,7 +131,7 @@ interface IDecryptionResult { } /* eslint-enable camelcase */ -interface IClearEvent { +export interface IClearEvent { type: string; content: Omit; unsigned?: IUnsigned; From d50e559f97a3d3a6420b4e6c60fbca7a9b3e85b1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 25 Jun 2021 10:21:28 +0100 Subject: [PATCH 104/124] MegolmDecryption::deviceId is not a valid field, sub it out for undefined as it isn't used meaningfully anyhow --- src/crypto/algorithms/megolm.ts | 6 +++--- src/crypto/olmlib.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index af80a9489..e111703a8 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -1539,7 +1539,7 @@ class MegolmDecryption extends DecryptionAlgorithm { await olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this.userId, - this.deviceId, + undefined, this.olmDevice, sender, device, @@ -1619,7 +1619,7 @@ class MegolmDecryption extends DecryptionAlgorithm { return this.olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this.userId, - this.deviceId, + undefined, this.olmDevice, userId, deviceInfo, @@ -1782,7 +1782,7 @@ class MegolmDecryption extends DecryptionAlgorithm { olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this.userId, - this.deviceId, + undefined, this.olmDevice, userId, deviceInfo, diff --git a/src/crypto/olmlib.ts b/src/crypto/olmlib.ts index f7ed067ee..7a5e3a26c 100644 --- a/src/crypto/olmlib.ts +++ b/src/crypto/olmlib.ts @@ -97,6 +97,7 @@ export async function encryptMessageForDevice( const payload = { sender: ourUserId, + // TODO this appears to no longer be used whatsoever sender_device: ourDeviceId, // Include the Ed25519 key so that the recipient knows what From 1b0a388eb3deed60a410452ecf72d6d8c171b54b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 25 Jun 2021 10:27:55 +0100 Subject: [PATCH 105/124] delint import --- src/crypto/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crypto/index.ts b/src/crypto/index.ts index b2b0c620e..070f8f5d9 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -28,7 +28,7 @@ import { ReEmitter } from '../ReEmitter'; import { logger } from '../logger'; import { OlmDevice } from "./OlmDevice"; import * as olmlib from "./olmlib"; -import {DeviceInfoMap, DeviceList } from "./DeviceList"; +import { DeviceInfoMap, DeviceList } from "./DeviceList"; import { DeviceInfo, IDevice } from "./deviceinfo"; import * as algorithms from "./algorithms"; import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from './CrossSigning'; From 55fb3d4e8e4570b95bf52dd8708cb0ca2b38f245 Mon Sep 17 00:00:00 2001 From: Aaron Raimist Date: Mon, 28 Jun 2021 22:32:21 -0500 Subject: [PATCH 106/124] Also prevent sending unless safe integer Synapse enforces this but I guess it doesn't hurt to also check here Signed-off-by: Aaron Raimist --- src/models/room-state.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/models/room-state.ts b/src/models/room-state.ts index 6b914a1d4..2b67ee79e 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -670,26 +670,26 @@ export class RoomState extends EventEmitter { powerLevels = powerLevelsEvent.getContent(); eventsLevels = powerLevels.events || {}; - if (Number.isFinite(powerLevels.state_default)) { + if (Number.isSafeInteger(powerLevels.state_default)) { stateDefault = powerLevels.state_default; } else { stateDefault = 50; } const userPowerLevel = powerLevels.users && powerLevels.users[userId]; - if (Number.isFinite(userPowerLevel)) { + if (Number.isSafeInteger(userPowerLevel)) { powerLevel = userPowerLevel; - } else if (Number.isFinite(powerLevels.users_default)) { + } else if (Number.isSafeInteger(powerLevels.users_default)) { powerLevel = powerLevels.users_default; } - if (Number.isFinite(powerLevels.events_default)) { + if (Number.isSafeInteger(powerLevels.events_default)) { eventsDefault = powerLevels.events_default; } } let requiredLevel = state ? stateDefault : eventsDefault; - if (Number.isFinite(eventsLevels[eventType])) { + if (Number.isSafeInteger(eventsLevels[eventType])) { requiredLevel = eventsLevels[eventType]; } return powerLevel >= requiredLevel; From 393047dec577f18f0ae555fc2a88ec9798e25b21 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 29 Jun 2021 11:34:59 +0100 Subject: [PATCH 107/124] Fix broken /messages filtering due to internal field changes in FilterComponent --- src/client.ts | 2 +- src/filter-component.ts | 25 ++++++++++++++++++++----- src/filter.ts | 4 +--- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/client.ts b/src/client.ts index eff9a9c3c..595ca1b6d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4427,7 +4427,7 @@ export class MatrixClient extends EventEmitter { // XXX: it's horrific that /messages' filter parameter doesn't match // /sync's one - see https://matrix.org/jira/browse/SPEC-451 filter = filter || {}; - Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent()); + Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent()?.toJSON()); } if (filter) { params.filter = JSON.stringify(filter); diff --git a/src/filter-component.ts b/src/filter-component.ts index 2d8f7eb51..e5cdc2ec3 100644 --- a/src/filter-component.ts +++ b/src/filter-component.ts @@ -25,7 +25,7 @@ import { MatrixEvent } from "./models/event"; * wildcard pattern. * @param {String} actualValue The value to be compared * @param {String} filterValue The filter pattern to be compared - * @return {bool} true if the actualValue matches the filterValue + * @return {boolean} true if the actualValue matches the filterValue */ function matchesWildcard(actualValue: string, filterValue: string): boolean { if (filterValue.endsWith("*")) { @@ -66,9 +66,9 @@ export class FilterComponent { /** * Checks with the filter component matches the given event * @param {MatrixEvent} event event to be checked against the filter - * @return {bool} true if the event matches the filter + * @return {boolean} true if the event matches the filter */ - check(event: MatrixEvent): boolean { + public check(event: MatrixEvent): boolean { return this.checkFields( event.getRoomId(), event.getSender(), @@ -77,6 +77,21 @@ export class FilterComponent { ); } + /** + * Converts the filter component into the form expected over the wire + */ + public toJSON(): object { + return { + types: this.filterJson.types || null, + not_types: this.filterJson.not_types || [], + rooms: this.filterJson.rooms || null, + not_rooms: this.filterJson.not_rooms || [], + senders: this.filterJson.senders || null, + not_senders: this.filterJson.not_senders || [], + contains_url: this.filterJson.contains_url || null, + }; + } + /** * Checks whether the filter component matches the given event fields. * @param {String} roomId the roomId for the event being checked @@ -123,7 +138,7 @@ export class FilterComponent { /** * Filters a list of events down to those which match this filter component - * @param {MatrixEvent[]} events Events to be checked againt the filter component + * @param {MatrixEvent[]} events Events to be checked against the filter component * @return {MatrixEvent[]} events which matched the filter component */ filter(events: MatrixEvent[]): MatrixEvent[] { @@ -132,7 +147,7 @@ export class FilterComponent { /** * Returns the limit field for a given filter component, providing a default of - * 10 if none is otherwise specified. Cargo-culted from Synapse. + * 10 if none is otherwise specified. Cargo-culted from Synapse. * @return {Number} the limit for this filter component. */ limit(): number { diff --git a/src/filter.ts b/src/filter.ts index a73d91178..f48245e79 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -168,9 +168,7 @@ export class Filter { } this.roomFilter = new FilterComponent(roomFilterFields); - this.roomTimelineFilter = new FilterComponent( - roomFilterJson ? (roomFilterJson.timeline || {}) : {}, - ); + this.roomTimelineFilter = new FilterComponent(roomFilterJson?.timeline || {}); // don't bother porting this from synapse yet: // this._room_state_filter = From a2442add5bbd2c83457bf67ee90536fe36fc7d6a Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Tue, 29 Jun 2021 13:46:42 +0100 Subject: [PATCH 108/124] Make filterId read/write and optional --- src/filter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/filter.ts b/src/filter.ts index f48245e79..859aeb0c7 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -96,7 +96,7 @@ export class Filter { private roomFilter: FilterComponent; private roomTimelineFilter: FilterComponent; - constructor(public readonly userId: string, public readonly filterId: string) {} + constructor(public readonly userId: string, public filterId?: string) {} /** * Get the ID of this filter on your homeserver (if known) From 17decea57671caac90cf00758f89839e5d9b8c93 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 29 Jun 2021 14:30:58 +0100 Subject: [PATCH 109/124] Prepare changelog for v12.0.1-rc.1 --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 609cf3c70..cdef97e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +Changes in [12.0.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.1-rc.1) (2021-06-29) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v12.0.0...v12.0.1-rc.1) + + * Fix broken /messages filtering due to internal field changes in + FilterComponent + [\#1759](https://github.com/matrix-org/matrix-js-sdk/pull/1759) + * Convert crypto index to TS + [\#1749](https://github.com/matrix-org/matrix-js-sdk/pull/1749) + * Fix typescript return types for membership update events + [\#1739](https://github.com/matrix-org/matrix-js-sdk/pull/1739) + * Fix types of MatrixEvent sender & target + [\#1753](https://github.com/matrix-org/matrix-js-sdk/pull/1753) + * Add keysharing on invites to File Tree Spaces + [\#1744](https://github.com/matrix-org/matrix-js-sdk/pull/1744) + * Convert Room and RoomState to Typescript + [\#1746](https://github.com/matrix-org/matrix-js-sdk/pull/1746) + * Improve type of IContent msgtype + [\#1752](https://github.com/matrix-org/matrix-js-sdk/pull/1752) + * Add PR template + [\#1747](https://github.com/matrix-org/matrix-js-sdk/pull/1747) + * Add functions to assist in immutability of Event objects + [\#1738](https://github.com/matrix-org/matrix-js-sdk/pull/1738) + * Convert Event Context to TS + [\#1742](https://github.com/matrix-org/matrix-js-sdk/pull/1742) + * Bump lodash from 4.17.20 to 4.17.21 + [\#1743](https://github.com/matrix-org/matrix-js-sdk/pull/1743) + * Add invite retries to file trees + [\#1740](https://github.com/matrix-org/matrix-js-sdk/pull/1740) + * Convert IndexedDBStore to TS + [\#1741](https://github.com/matrix-org/matrix-js-sdk/pull/1741) + * Convert additional files to typescript + [\#1736](https://github.com/matrix-org/matrix-js-sdk/pull/1736) + 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) From 8f19ab066c70fb626a44f850c04ad2276c57d148 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 29 Jun 2021 14:30:59 +0100 Subject: [PATCH 110/124] v12.0.1-rc.1 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index a5d28feef..2c03fc955 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "12.0.0", + "version": "12.0.1-rc.1", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", @@ -28,7 +28,7 @@ "keywords": [ "matrix-org" ], - "main": "./src/index.ts", + "main": "./lib/index.js", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -109,5 +109,6 @@ "coverageReporters": [ "text" ] - } + }, + "typings": "./lib/index.d.ts" } From 4b29f02f1c856af9c76d4844db0b3039b909feb3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jul 2021 09:53:55 +0100 Subject: [PATCH 111/124] Convert EventTimeline, EventTimelineSet and TimelineWindow to TS --- src/client.ts | 10 +- src/crypto/CrossSigning.ts | 2 +- src/models/event-context.ts | 6 +- src/models/event-timeline-set.js | 848 -------------------- src/models/event-timeline-set.ts | 874 +++++++++++++++++++++ src/models/event-timeline.js | 398 ---------- src/models/event-timeline.ts | 416 ++++++++++ src/models/event.ts | 2 +- src/models/relations.ts | 2 +- src/{service-types.js => service-types.ts} | 10 +- src/timeline-window.js | 521 ------------ src/timeline-window.ts | 526 +++++++++++++ 12 files changed, 1830 insertions(+), 1785 deletions(-) delete mode 100644 src/models/event-timeline-set.js create mode 100644 src/models/event-timeline-set.ts delete mode 100644 src/models/event-timeline.js create mode 100644 src/models/event-timeline.ts rename src/{service-types.js => service-types.ts} (72%) delete mode 100644 src/timeline-window.js create mode 100644 src/timeline-window.ts diff --git a/src/client.ts b/src/client.ts index a0b8cb9e2..c967a17b7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4502,7 +4502,7 @@ export class MatrixClient extends EventEmitter { return Promise.resolve(false); } - const pendingRequest = eventTimeline._paginationRequests[dir]; + const pendingRequest = eventTimeline.paginationRequests[dir]; if (pendingRequest) { // already a request in progress - return the existing promise @@ -4551,9 +4551,9 @@ export class MatrixClient extends EventEmitter { } return res.next_token ? true : false; }).finally(() => { - eventTimeline._paginationRequests[dir] = null; + eventTimeline.paginationRequests[dir] = null; }); - eventTimeline._paginationRequests[dir] = promise; + eventTimeline.paginationRequests[dir] = promise; } else { const room = this.getRoom(eventTimeline.getRoomId()); if (!room) { @@ -4585,9 +4585,9 @@ export class MatrixClient extends EventEmitter { } return res.end != res.start; }).finally(() => { - eventTimeline._paginationRequests[dir] = null; + eventTimeline.paginationRequests[dir] = null; }); - eventTimeline._paginationRequests[dir] = promise; + eventTimeline.paginationRequests[dir] = promise; } return promise; diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index 720a3f5bd..0d4823051 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -292,7 +292,7 @@ export class CrossSigningInfo extends EventEmitter { CrossSigningLevel.USER_SIGNING | CrossSigningLevel.SELF_SIGNING ); - } else if (level === 0) { + } else if (level === 0 as CrossSigningLevel) { return; } diff --git a/src/models/event-context.ts b/src/models/event-context.ts index 95bc83e6c..18c64afee 100644 --- a/src/models/event-context.ts +++ b/src/models/event-context.ts @@ -15,11 +15,7 @@ limitations under the License. */ import { MatrixEvent } from "./event"; - -enum Direction { - Backward = "b", - Forward = "f", -} +import { Direction } from "./event-timeline"; /** * @module models/event-context diff --git a/src/models/event-timeline-set.js b/src/models/event-timeline-set.js deleted file mode 100644 index 5835e5343..000000000 --- a/src/models/event-timeline-set.js +++ /dev/null @@ -1,848 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 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 models/event-timeline-set - */ - -import { EventEmitter } from "events"; -import { EventTimeline } from "./event-timeline"; -import { EventStatus } from "./event"; -import * as utils from "../utils"; -import { logger } from '../logger'; -import { Relations } from './relations'; - -// var DEBUG = false; -const DEBUG = true; - -let debuglog; -if (DEBUG) { - // using bind means that we get to keep useful line numbers in the console - debuglog = logger.log.bind(logger); -} else { - debuglog = function() {}; -} - -/** - * Construct a set of EventTimeline objects, typically on behalf of a given - * room. A room may have multiple EventTimelineSets for different levels - * of filtering. The global notification list is also an EventTimelineSet, but - * lacks a room. - * - *

This is an ordered sequence of timelines, which may or may not - * be continuous. Each timeline lists a series of events, as well as tracking - * the room state at the start and the end of the timeline (if appropriate). - * It also tracks forward and backward pagination tokens, as well as containing - * links to the next timeline in the sequence. - * - *

There is one special timeline - the 'live' timeline, which represents the - * timeline to which events are being added in real-time as they are received - * from the /sync API. Note that you should not retain references to this - * timeline - even if it is the current timeline right now, it may not remain - * so if the server gives us a timeline gap in /sync. - * - *

In order that we can find events from their ids later, we also maintain a - * map from event_id to timeline and index. - * - * @constructor - * @param {?Room} room - * Room for this timelineSet. May be null for non-room cases, such as the - * notification timeline. - * @param {Object} opts Options inherited from Room. - * - * @param {boolean} [opts.timelineSupport = false] - * Set to true to enable improved timeline support. - * @param {Object} [opts.filter = null] - * The filter object, if any, for this timelineSet. - * @param {boolean} [opts.unstableClientRelationAggregation = false] - * Optional. Set to true to enable client-side aggregation of event relations - * via `getRelationsForEvent`. - * This feature is currently unstable and the API may change without notice. - */ -export function EventTimelineSet(room, opts) { - this.room = room; - - this._timelineSupport = Boolean(opts.timelineSupport); - this._liveTimeline = new EventTimeline(this); - this._unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation; - - // just a list - *not* ordered. - this._timelines = [this._liveTimeline]; - this._eventIdToTimeline = {}; - - this._filter = opts.filter || null; - - if (this._unstableClientRelationAggregation) { - // A tree of objects to access a set of relations for an event, as in: - // this._relations[relatesToEventId][relationType][relationEventType] - this._relations = {}; - } -} -utils.inherits(EventTimelineSet, EventEmitter); - -/** - * Get all the timelines in this set - * @return {module:models/event-timeline~EventTimeline[]} the timelines in this set - */ -EventTimelineSet.prototype.getTimelines = function() { - return this._timelines; -}; -/** - * Get the filter object this timeline set is filtered on, if any - * @return {?Filter} the optional filter for this timelineSet - */ -EventTimelineSet.prototype.getFilter = function() { - return this._filter; -}; - -/** - * Set the filter object this timeline set is filtered on - * (passed to the server when paginating via /messages). - * @param {Filter} filter the filter for this timelineSet - */ -EventTimelineSet.prototype.setFilter = function(filter) { - this._filter = filter; -}; - -/** - * Get the list of pending sent events for this timelineSet's room, filtered - * by the timelineSet's filter if appropriate. - * - * @return {module:models/event.MatrixEvent[]} A list of the sent events - * waiting for remote echo. - * - * @throws If opts.pendingEventOrdering was not 'detached' - */ -EventTimelineSet.prototype.getPendingEvents = function() { - if (!this.room) { - return []; - } - - if (this._filter) { - return this._filter.filterRoomTimeline(this.room.getPendingEvents()); - } else { - return this.room.getPendingEvents(); - } -}; - -/** - * Get the live timeline for this room. - * - * @return {module:models/event-timeline~EventTimeline} live timeline - */ -EventTimelineSet.prototype.getLiveTimeline = function() { - return this._liveTimeline; -}; - -/** - * Return the timeline (if any) this event is in. - * @param {String} eventId the eventId being sought - * @return {module:models/event-timeline~EventTimeline} timeline - */ -EventTimelineSet.prototype.eventIdToTimeline = function(eventId) { - return this._eventIdToTimeline[eventId]; -}; - -/** - * Track a new event as if it were in the same timeline as an old event, - * replacing it. - * @param {String} oldEventId event ID of the original event - * @param {String} newEventId event ID of the replacement event - */ -EventTimelineSet.prototype.replaceEventId = function(oldEventId, newEventId) { - const existingTimeline = this._eventIdToTimeline[oldEventId]; - if (existingTimeline) { - delete this._eventIdToTimeline[oldEventId]; - this._eventIdToTimeline[newEventId] = existingTimeline; - } -}; - -/** - * Reset the live timeline, and start a new one. - * - *

This is used when /sync returns a 'limited' timeline. - * - * @param {string=} backPaginationToken token for back-paginating the new timeline - * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, - * if absent or null, all timelines are reset. - * - * @fires module:client~MatrixClient#event:"Room.timelineReset" - */ -EventTimelineSet.prototype.resetLiveTimeline = function( - backPaginationToken, forwardPaginationToken, -) { - // Each EventTimeline has RoomState objects tracking the state at the start - // and end of that timeline. The copies at the end of the live timeline are - // special because they will have listeners attached to monitor changes to - // the current room state, so we move this RoomState from the end of the - // current live timeline to the end of the new one and, if necessary, - // replace it with a newly created one. We also make a copy for the start - // of the new timeline. - - // if timeline support is disabled, forget about the old timelines - const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken; - - const oldTimeline = this._liveTimeline; - const newTimeline = resetAllTimelines ? - oldTimeline.forkLive(EventTimeline.FORWARDS) : - oldTimeline.fork(EventTimeline.FORWARDS); - - if (resetAllTimelines) { - this._timelines = [newTimeline]; - this._eventIdToTimeline = {}; - } else { - this._timelines.push(newTimeline); - } - - if (forwardPaginationToken) { - // Now set the forward pagination token on the old live timeline - // so it can be forward-paginated. - oldTimeline.setPaginationToken( - forwardPaginationToken, EventTimeline.FORWARDS, - ); - } - - // make sure we set the pagination token before firing timelineReset, - // otherwise clients which start back-paginating will fail, and then get - // stuck without realising that they *can* back-paginate. - newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); - - // Now we can swap the live timeline to the new one. - this._liveTimeline = newTimeline; - this.emit("Room.timelineReset", this.room, this, resetAllTimelines); -}; - -/** - * Get the timeline which contains the given event, if any - * - * @param {string} eventId event ID to look for - * @return {?module:models/event-timeline~EventTimeline} timeline containing - * the given event, or null if unknown - */ -EventTimelineSet.prototype.getTimelineForEvent = function(eventId) { - const res = this._eventIdToTimeline[eventId]; - return (res === undefined) ? null : res; -}; - -/** - * Get an event which is stored in our timelines - * - * @param {string} eventId event ID to look for - * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown - */ -EventTimelineSet.prototype.findEventById = function(eventId) { - const tl = this.getTimelineForEvent(eventId); - if (!tl) { - return undefined; - } - return tl.getEvents().find(function(ev) { - return ev.getId() == eventId; - }); -}; - -/** - * Add a new timeline to this timeline list - * - * @return {module:models/event-timeline~EventTimeline} newly-created timeline - */ -EventTimelineSet.prototype.addTimeline = function() { - if (!this._timelineSupport) { - throw new Error("timeline support is disabled. Set the 'timelineSupport'" + - " parameter to true when creating MatrixClient to enable" + - " it."); - } - - const timeline = new EventTimeline(this); - this._timelines.push(timeline); - return timeline; -}; - -/** - * Add events to a timeline - * - *

Will fire "Room.timeline" for each event added. - * - * @param {MatrixEvent[]} events A list of events to add. - * - * @param {boolean} toStartOfTimeline True to add these events to the start - * (oldest) instead of the end (newest) of the timeline. If true, the oldest - * event will be the last element of 'events'. - * - * @param {module:models/event-timeline~EventTimeline} timeline timeline to - * add events to. - * - * @param {string=} paginationToken token for the next batch of events - * - * @fires module:client~MatrixClient#event:"Room.timeline" - * - */ -EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimeline, - timeline, paginationToken) { - if (!timeline) { - throw new Error( - "'timeline' not specified for EventTimelineSet.addEventsToTimeline", - ); - } - - if (!toStartOfTimeline && timeline == this._liveTimeline) { - throw new Error( - "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " + - "the live timeline - use Room.addLiveEvents instead", - ); - } - - if (this._filter) { - events = this._filter.filterRoomTimeline(events); - if (!events.length) { - return; - } - } - - const direction = toStartOfTimeline ? EventTimeline.BACKWARDS : - EventTimeline.FORWARDS; - const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS : - EventTimeline.BACKWARDS; - - // Adding events to timelines can be quite complicated. The following - // illustrates some of the corner-cases. - // - // Let's say we start by knowing about four timelines. timeline3 and - // timeline4 are neighbours: - // - // timeline1 timeline2 timeline3 timeline4 - // [M] [P] [S] <------> [T] - // - // Now we paginate timeline1, and get the following events from the server: - // [M, N, P, R, S, T, U]. - // - // 1. First, we ignore event M, since we already know about it. - // - // 2. Next, we append N to timeline 1. - // - // 3. Next, we don't add event P, since we already know about it, - // but we do link together the timelines. We now have: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P] [S] <------> [T] - // - // 4. Now we add event R to timeline2: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] [S] <------> [T] - // - // Note that we have switched the timeline we are working on from - // timeline1 to timeline2. - // - // 5. We ignore event S, but again join the timelines: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] <---> [S] <------> [T] - // - // 6. We ignore event T, and the timelines are already joined, so there - // is nothing to do. - // - // 7. Finally, we add event U to timeline4: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] <---> [S] <------> [T, U] - // - // The important thing to note in the above is what happened when we - // already knew about a given event: - // - // - if it was appropriate, we joined up the timelines (steps 3, 5). - // - in any case, we started adding further events to the timeline which - // contained the event we knew about (steps 3, 5, 6). - // - // - // So much for adding events to the timeline. But what do we want to do - // with the pagination token? - // - // In the case above, we will be given a pagination token which tells us how to - // get events beyond 'U' - in this case, it makes sense to store this - // against timeline4. But what if timeline4 already had 'U' and beyond? in - // that case, our best bet is to throw away the pagination token we were - // given and stick with whatever token timeline4 had previously. In short, - // we want to only store the pagination token if the last event we receive - // is one we didn't previously know about. - // - // We make an exception for this if it turns out that we already knew about - // *all* of the events, and we weren't able to join up any timelines. When - // that happens, it means our existing pagination token is faulty, since it - // is only telling us what we already know. Rather than repeatedly - // paginating with the same token, we might as well use the new pagination - // token in the hope that we eventually work our way out of the mess. - - let didUpdate = false; - let lastEventWasNew = false; - for (let i = 0; i < events.length; i++) { - const event = events[i]; - const eventId = event.getId(); - - const existingTimeline = this._eventIdToTimeline[eventId]; - - if (!existingTimeline) { - // we don't know about this event yet. Just add it to the timeline. - this.addEventToTimeline(event, timeline, toStartOfTimeline); - lastEventWasNew = true; - didUpdate = true; - continue; - } - - lastEventWasNew = false; - - if (existingTimeline == timeline) { - debuglog("Event " + eventId + " already in timeline " + timeline); - continue; - } - - const neighbour = timeline.getNeighbouringTimeline(direction); - if (neighbour) { - // this timeline already has a neighbour in the relevant direction; - // let's assume the timelines are already correctly linked up, and - // skip over to it. - // - // there's probably some edge-case here where we end up with an - // event which is in a timeline a way down the chain, and there is - // a break in the chain somewhere. But I can't really imagine how - // that would happen, so I'm going to ignore it for now. - // - if (existingTimeline == neighbour) { - debuglog("Event " + eventId + " in neighbouring timeline - " + - "switching to " + existingTimeline); - } else { - debuglog("Event " + eventId + " already in a different " + - "timeline " + existingTimeline); - } - timeline = existingTimeline; - continue; - } - - // time to join the timelines. - logger.info("Already have timeline for " + eventId + - " - joining timeline " + timeline + " to " + - existingTimeline); - - // Variables to keep the line length limited below. - const existingIsLive = existingTimeline === this._liveTimeline; - const timelineIsLive = timeline === this._liveTimeline; - - const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive; - const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive; - - if (backwardsIsLive || forwardsIsLive) { - // The live timeline should never be spliced into a non-live position. - // We use independent logging to better discover the problem at a glance. - if (backwardsIsLive) { - logger.warn( - "Refusing to set a preceding existingTimeLine on our " + - "timeline as the existingTimeLine is live (" + existingTimeline + ")", - ); - } - if (forwardsIsLive) { - logger.warn( - "Refusing to set our preceding timeline on a existingTimeLine " + - "as our timeline is live (" + timeline + ")", - ); - } - continue; // abort splicing - try next event - } - - timeline.setNeighbouringTimeline(existingTimeline, direction); - existingTimeline.setNeighbouringTimeline(timeline, inverseDirection); - - timeline = existingTimeline; - didUpdate = true; - } - - // see above - if the last event was new to us, or if we didn't find any - // new information, we update the pagination token for whatever - // timeline we ended up on. - if (lastEventWasNew || !didUpdate) { - if (direction === EventTimeline.FORWARDS && timeline === this._liveTimeline) { - logger.warn({ lastEventWasNew, didUpdate }); // for debugging - logger.warn( - `Refusing to set forwards pagination token of live timeline ` + - `${timeline} to ${paginationToken}`, - ); - return; - } - timeline.setPaginationToken(paginationToken, direction); - } -}; - -/** - * Add an event to the end of this live timeline. - * - * @param {MatrixEvent} event Event to be added - * @param {string?} duplicateStrategy 'ignore' or 'replace' - * @param {boolean} fromCache whether the sync response came from cache - */ -EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy, fromCache) { - if (this._filter) { - const events = this._filter.filterRoomTimeline([event]); - if (!events.length) { - return; - } - } - - const timeline = this._eventIdToTimeline[event.getId()]; - if (timeline) { - if (duplicateStrategy === "replace") { - debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + - event.getId()); - const tlEvents = timeline.getEvents(); - for (let j = 0; j < tlEvents.length; j++) { - if (tlEvents[j].getId() === event.getId()) { - // still need to set the right metadata on this event - EventTimeline.setEventMetadata( - event, - timeline.getState(EventTimeline.FORWARDS), - false, - ); - - if (!tlEvents[j].encryptedType) { - tlEvents[j] = event; - } - - // XXX: we need to fire an event when this happens. - break; - } - } - } else { - debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + - event.getId()); - } - return; - } - - this.addEventToTimeline(event, this._liveTimeline, false, fromCache); -}; - -/** - * Add event to the given timeline, and emit Room.timeline. Assumes - * we have already checked we don't know about this event. - * - * Will fire "Room.timeline" for each event added. - * - * @param {MatrixEvent} event - * @param {EventTimeline} timeline - * @param {boolean} toStartOfTimeline - * @param {boolean} fromCache whether the sync response came from cache - * - * @fires module:client~MatrixClient#event:"Room.timeline" - */ -EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, - toStartOfTimeline, fromCache) { - const eventId = event.getId(); - timeline.addEvent(event, toStartOfTimeline); - this._eventIdToTimeline[eventId] = timeline; - - this.setRelationsTarget(event); - this.aggregateRelations(event); - - const data = { - timeline: timeline, - liveEvent: !toStartOfTimeline && timeline == this._liveTimeline && !fromCache, - }; - this.emit("Room.timeline", event, this.room, - Boolean(toStartOfTimeline), false, data); -}; - -/** - * Replaces event with ID oldEventId with one with newEventId, if oldEventId is - * recognised. Otherwise, add to the live timeline. Used to handle remote echos. - * - * @param {MatrixEvent} localEvent the new event to be added to the timeline - * @param {String} oldEventId the ID of the original event - * @param {boolean} newEventId the ID of the replacement event - * - * @fires module:client~MatrixClient#event:"Room.timeline" - */ -EventTimelineSet.prototype.handleRemoteEcho = function(localEvent, oldEventId, - newEventId) { - // XXX: why don't we infer newEventId from localEvent? - const existingTimeline = this._eventIdToTimeline[oldEventId]; - if (existingTimeline) { - delete this._eventIdToTimeline[oldEventId]; - this._eventIdToTimeline[newEventId] = existingTimeline; - } else { - if (this._filter) { - if (this._filter.filterRoomTimeline([localEvent]).length) { - this.addEventToTimeline(localEvent, this._liveTimeline, false); - } - } else { - this.addEventToTimeline(localEvent, this._liveTimeline, false); - } - } -}; - -/** - * Removes a single event from this room. - * - * @param {String} eventId The id of the event to remove - * - * @return {?MatrixEvent} the removed event, or null if the event was not found - * in this room. - */ -EventTimelineSet.prototype.removeEvent = function(eventId) { - const timeline = this._eventIdToTimeline[eventId]; - if (!timeline) { - return null; - } - - const removed = timeline.removeEvent(eventId); - if (removed) { - delete this._eventIdToTimeline[eventId]; - const data = { - timeline: timeline, - }; - this.emit("Room.timeline", removed, this.room, undefined, true, data); - } - return removed; -}; - -/** - * Determine where two events appear in the timeline relative to one another - * - * @param {string} eventId1 The id of the first event - * @param {string} eventId2 The id of the second event - - * @return {?number} a number less than zero if eventId1 precedes eventId2, and - * greater than zero if eventId1 succeeds eventId2. zero if they are the - * same event; null if we can't tell (either because we don't know about one - * of the events, or because they are in separate timelines which don't join - * up). - */ -EventTimelineSet.prototype.compareEventOrdering = function(eventId1, eventId2) { - if (eventId1 == eventId2) { - // optimise this case - return 0; - } - - const timeline1 = this._eventIdToTimeline[eventId1]; - const timeline2 = this._eventIdToTimeline[eventId2]; - - if (timeline1 === undefined) { - return null; - } - if (timeline2 === undefined) { - return null; - } - - if (timeline1 === timeline2) { - // both events are in the same timeline - figure out their - // relative indices - let idx1; - let idx2; - const events = timeline1.getEvents(); - for (let idx = 0; idx < events.length && - (idx1 === undefined || idx2 === undefined); idx++) { - const evId = events[idx].getId(); - if (evId == eventId1) { - idx1 = idx; - } - if (evId == eventId2) { - idx2 = idx; - } - } - return idx1 - idx2; - } - - // the events are in different timelines. Iterate through the - // linkedlist to see which comes first. - - // first work forwards from timeline1 - let tl = timeline1; - while (tl) { - if (tl === timeline2) { - // timeline1 is before timeline2 - return -1; - } - tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS); - } - - // now try backwards from timeline1 - tl = timeline1; - while (tl) { - if (tl === timeline2) { - // timeline2 is before timeline1 - return 1; - } - tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS); - } - - // the timelines are not contiguous. - return null; -}; - -/** - * Get a collection of relations to a given event in this timeline set. - * - * @param {String} eventId - * The ID of the event that you'd like to access relation events for. - * For example, with annotations, this would be the ID of the event being annotated. - * @param {String} relationType - * The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc. - * @param {String} eventType - * The relation event's type, such as "m.reaction", etc. - * @throws If eventId, relationType or eventType - * are not valid. - * - * @returns {?Relations} - * A container for relation events or undefined if there are no relation events for - * the relationType. - */ -EventTimelineSet.prototype.getRelationsForEvent = function( - eventId, relationType, eventType, -) { - if (!this._unstableClientRelationAggregation) { - throw new Error("Client-side relation aggregation is disabled"); - } - - if (!eventId || !relationType || !eventType) { - throw new Error("Invalid arguments for `getRelationsForEvent`"); - } - - // debuglog("Getting relations for: ", eventId, relationType, eventType); - - const relationsForEvent = this._relations[eventId] || {}; - const relationsWithRelType = relationsForEvent[relationType] || {}; - return relationsWithRelType[eventType]; -}; - -/** - * Set an event as the target event if any Relations exist for it already - * - * @param {MatrixEvent} event - * The event to check as relation target. - */ -EventTimelineSet.prototype.setRelationsTarget = function(event) { - if (!this._unstableClientRelationAggregation) { - return; - } - - const relationsForEvent = this._relations[event.getId()]; - if (!relationsForEvent) { - return; - } - - for (const relationsWithRelType of Object.values(relationsForEvent)) { - for (const relationsWithEventType of Object.values(relationsWithRelType)) { - relationsWithEventType.setTargetEvent(event); - } - } -}; - -/** - * Add relation events to the relevant relation collection. - * - * @param {MatrixEvent} event - * The new relation event to be aggregated. - */ -EventTimelineSet.prototype.aggregateRelations = function(event) { - if (!this._unstableClientRelationAggregation) { - return; - } - - if (event.isRedacted() || event.status === EventStatus.CANCELLED) { - return; - } - - // If the event is currently encrypted, wait until it has been decrypted. - if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { - event.once("Event.decrypted", () => { - this.aggregateRelations(event); - }); - return; - } - - const relation = event.getRelation(); - if (!relation) { - return; - } - - const relatesToEventId = relation.event_id; - const relationType = relation.rel_type; - const eventType = event.getType(); - - // debuglog("Aggregating relation: ", event.getId(), eventType, relation); - - let relationsForEvent = this._relations[relatesToEventId]; - if (!relationsForEvent) { - relationsForEvent = this._relations[relatesToEventId] = {}; - } - let relationsWithRelType = relationsForEvent[relationType]; - if (!relationsWithRelType) { - relationsWithRelType = relationsForEvent[relationType] = {}; - } - let relationsWithEventType = relationsWithRelType[eventType]; - - let relatesToEvent; - if (!relationsWithEventType) { - relationsWithEventType = relationsWithRelType[eventType] = new Relations( - relationType, - eventType, - this.room, - ); - relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId); - if (relatesToEvent) { - relationsWithEventType.setTargetEvent(relatesToEvent); - } - } - - relationsWithEventType.addEvent(event); -}; - -/** - * Fires whenever the timeline in a room is updated. - * @event module:client~MatrixClient#"Room.timeline" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {?Room} room The room, if any, whose timeline was updated. - * @param {boolean} toStartOfTimeline True if this event was added to the start - * @param {boolean} removed True if this event has just been removed from the timeline - * (beginning; oldest) of the timeline e.g. due to pagination. - * - * @param {object} data more data about the event - * - * @param {module:models/event-timeline.EventTimeline} data.timeline the timeline the - * event was added to/removed from - * - * @param {boolean} data.liveEvent true if the event was a real-time event - * added to the end of the live timeline - * - * @example - * matrixClient.on("Room.timeline", - * function(event, room, toStartOfTimeline, removed, data) { - * if (!toStartOfTimeline && data.liveEvent) { - * var messageToAppend = room.timeline.[room.timeline.length - 1]; - * } - * }); - */ - -/** - * Fires whenever the live timeline in a room is reset. - * - * When we get a 'limited' sync (for example, after a network outage), we reset - * the live timeline to be empty before adding the recent events to the new - * timeline. This event is fired after the timeline is reset, and before the - * new events are added. - * - * @event module:client~MatrixClient#"Room.timelineReset" - * @param {Room} room The room whose live timeline was reset, if any - * @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset - * @param {boolean} resetAllTimelines True if all timelines were reset. - */ diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts new file mode 100644 index 000000000..d24252516 --- /dev/null +++ b/src/models/event-timeline-set.ts @@ -0,0 +1,874 @@ +/* +Copyright 2016 - 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 models/event-timeline-set + */ + +import { EventEmitter } from "events"; + +import { EventTimeline } from "./event-timeline"; +import { EventStatus, MatrixEvent } from "./event"; +import { logger } from '../logger'; +import { Relations } from './relations'; +import { Room } from "./room"; +import { Filter } from "../filter"; +import { EventType, RelationType } from "../@types/event"; + +// var DEBUG = false; +const DEBUG = true; + +let debuglog; +if (DEBUG) { + // using bind means that we get to keep useful line numbers in the console + debuglog = logger.log.bind(logger); +} else { + debuglog = function() {}; +} + +interface IOpts { + timelineSupport?: boolean; + filter?: Filter; + unstableClientRelationAggregation?: boolean; +} + +export class EventTimelineSet extends EventEmitter { + private readonly timelineSupport: boolean; + private unstableClientRelationAggregation: boolean; + private liveTimeline: EventTimeline; + private timelines: EventTimeline[]; + private _eventIdToTimeline: Record; + private filter?: Filter; + private relations: Record>>; + + /** + * Construct a set of EventTimeline objects, typically on behalf of a given + * room. A room may have multiple EventTimelineSets for different levels + * of filtering. The global notification list is also an EventTimelineSet, but + * lacks a room. + * + *

This is an ordered sequence of timelines, which may or may not + * be continuous. Each timeline lists a series of events, as well as tracking + * the room state at the start and the end of the timeline (if appropriate). + * It also tracks forward and backward pagination tokens, as well as containing + * links to the next timeline in the sequence. + * + *

There is one special timeline - the 'live' timeline, which represents the + * timeline to which events are being added in real-time as they are received + * from the /sync API. Note that you should not retain references to this + * timeline - even if it is the current timeline right now, it may not remain + * so if the server gives us a timeline gap in /sync. + * + *

In order that we can find events from their ids later, we also maintain a + * map from event_id to timeline and index. + * + * @constructor + * @param {?Room} room + * Room for this timelineSet. May be null for non-room cases, such as the + * notification timeline. + * @param {Object} opts Options inherited from Room. + * + * @param {boolean} [opts.timelineSupport = false] + * Set to true to enable improved timeline support. + * @param {Object} [opts.filter = null] + * The filter object, if any, for this timelineSet. + * @param {boolean} [opts.unstableClientRelationAggregation = false] + * Optional. Set to true to enable client-side aggregation of event relations + * via `getRelationsForEvent`. + * This feature is currently unstable and the API may change without notice. + */ + constructor(public readonly room: Room, opts: IOpts) { + super(); + + this.timelineSupport = Boolean(opts.timelineSupport); + this.liveTimeline = new EventTimeline(this); + this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation; + + // just a list - *not* ordered. + this.timelines = [this.liveTimeline]; + this._eventIdToTimeline = {}; + + this.filter = opts.filter; + + if (this.unstableClientRelationAggregation) { + // A tree of objects to access a set of relations for an event, as in: + // this.relations[relatesToEventId][relationType][relationEventType] + this.relations = {}; + } + } + + /** + * Get all the timelines in this set + * @return {module:models/event-timeline~EventTimeline[]} the timelines in this set + */ + public getTimelines(): EventTimeline[] { + return this.timelines; + } + + /** + * Get the filter object this timeline set is filtered on, if any + * @return {?Filter} the optional filter for this timelineSet + */ + public getFilter(): Filter | undefined { + return this.filter; + } + + /** + * Set the filter object this timeline set is filtered on + * (passed to the server when paginating via /messages). + * @param {Filter} filter the filter for this timelineSet + */ + public setFilter(filter?: Filter): void { + this.filter = filter; + } + + /** + * Get the list of pending sent events for this timelineSet's room, filtered + * by the timelineSet's filter if appropriate. + * + * @return {module:models/event.MatrixEvent[]} A list of the sent events + * waiting for remote echo. + * + * @throws If opts.pendingEventOrdering was not 'detached' + */ + public getPendingEvents(): MatrixEvent[] { + if (!this.room) { + return []; + } + + if (this.filter) { + return this.filter.filterRoomTimeline(this.room.getPendingEvents()); + } else { + return this.room.getPendingEvents(); + } + } + + /** + * Get the live timeline for this room. + * + * @return {module:models/event-timeline~EventTimeline} live timeline + */ + public getLiveTimeline(): EventTimeline { + return this.liveTimeline; + } + + /** + * Return the timeline (if any) this event is in. + * @param {String} eventId the eventId being sought + * @return {module:models/event-timeline~EventTimeline} timeline + */ + public eventIdToTimeline(eventId: string): EventTimeline { + return this._eventIdToTimeline[eventId]; + } + + /** + * Track a new event as if it were in the same timeline as an old event, + * replacing it. + * @param {String} oldEventId event ID of the original event + * @param {String} newEventId event ID of the replacement event + */ + public replaceEventId(oldEventId: string, newEventId: string): void { + const existingTimeline = this._eventIdToTimeline[oldEventId]; + if (existingTimeline) { + delete this._eventIdToTimeline[oldEventId]; + this._eventIdToTimeline[newEventId] = existingTimeline; + } + } + + /** + * Reset the live timeline, and start a new one. + * + *

This is used when /sync returns a 'limited' timeline. + * + * @param {string=} backPaginationToken token for back-paginating the new timeline + * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, + * if absent or null, all timelines are reset. + * + * @fires module:client~MatrixClient#event:"Room.timelineReset" + */ + public resetLiveTimeline(backPaginationToken: string, forwardPaginationToken?: string): void { + // Each EventTimeline has RoomState objects tracking the state at the start + // and end of that timeline. The copies at the end of the live timeline are + // special because they will have listeners attached to monitor changes to + // the current room state, so we move this RoomState from the end of the + // current live timeline to the end of the new one and, if necessary, + // replace it with a newly created one. We also make a copy for the start + // of the new timeline. + + // if timeline support is disabled, forget about the old timelines + const resetAllTimelines = !this.timelineSupport || !forwardPaginationToken; + + const oldTimeline = this.liveTimeline; + const newTimeline = resetAllTimelines ? + oldTimeline.forkLive(EventTimeline.FORWARDS) : + oldTimeline.fork(EventTimeline.FORWARDS); + + if (resetAllTimelines) { + this.timelines = [newTimeline]; + this._eventIdToTimeline = {}; + } else { + this.timelines.push(newTimeline); + } + + if (forwardPaginationToken) { + // Now set the forward pagination token on the old live timeline + // so it can be forward-paginated. + oldTimeline.setPaginationToken( + forwardPaginationToken, EventTimeline.FORWARDS, + ); + } + + // make sure we set the pagination token before firing timelineReset, + // otherwise clients which start back-paginating will fail, and then get + // stuck without realising that they *can* back-paginate. + newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); + + // Now we can swap the live timeline to the new one. + this.liveTimeline = newTimeline; + this.emit("Room.timelineReset", this.room, this, resetAllTimelines); + } + + /** + * Get the timeline which contains the given event, if any + * + * @param {string} eventId event ID to look for + * @return {?module:models/event-timeline~EventTimeline} timeline containing + * the given event, or null if unknown + */ + public getTimelineForEvent(eventId: string): EventTimeline | null { + const res = this._eventIdToTimeline[eventId]; + return (res === undefined) ? null : res; + } + + /** + * Get an event which is stored in our timelines + * + * @param {string} eventId event ID to look for + * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown + */ + public findEventById(eventId: string): MatrixEvent | undefined { + const tl = this.getTimelineForEvent(eventId); + if (!tl) { + return undefined; + } + return tl.getEvents().find(function(ev) { + return ev.getId() == eventId; + }); + } + + /** + * Add a new timeline to this timeline list + * + * @return {module:models/event-timeline~EventTimeline} newly-created timeline + */ + public addTimeline(): EventTimeline { + if (!this.timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + + " parameter to true when creating MatrixClient to enable" + + " it."); + } + + const timeline = new EventTimeline(this); + this.timelines.push(timeline); + return timeline; + } + + /** + * Add events to a timeline + * + *

Will fire "Room.timeline" for each event added. + * + * @param {MatrixEvent[]} events A list of events to add. + * + * @param {boolean} toStartOfTimeline True to add these events to the start + * (oldest) instead of the end (newest) of the timeline. If true, the oldest + * event will be the last element of 'events'. + * + * @param {module:models/event-timeline~EventTimeline} timeline timeline to + * add events to. + * + * @param {string=} paginationToken token for the next batch of events + * + * @fires module:client~MatrixClient#event:"Room.timeline" + * + */ + public addEventsToTimeline( + events: MatrixEvent[], + toStartOfTimeline: boolean, + timeline: EventTimeline, + paginationToken: string, + ): void { + if (!timeline) { + throw new Error( + "'timeline' not specified for EventTimelineSet.addEventsToTimeline", + ); + } + + if (!toStartOfTimeline && timeline == this.liveTimeline) { + throw new Error( + "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " + + "the live timeline - use Room.addLiveEvents instead", + ); + } + + if (this.filter) { + events = this.filter.filterRoomTimeline(events); + if (!events.length) { + return; + } + } + + const direction = toStartOfTimeline ? EventTimeline.BACKWARDS : + EventTimeline.FORWARDS; + const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS : + EventTimeline.BACKWARDS; + + // Adding events to timelines can be quite complicated. The following + // illustrates some of the corner-cases. + // + // Let's say we start by knowing about four timelines. timeline3 and + // timeline4 are neighbours: + // + // timeline1 timeline2 timeline3 timeline4 + // [M] [P] [S] <------> [T] + // + // Now we paginate timeline1, and get the following events from the server: + // [M, N, P, R, S, T, U]. + // + // 1. First, we ignore event M, since we already know about it. + // + // 2. Next, we append N to timeline 1. + // + // 3. Next, we don't add event P, since we already know about it, + // but we do link together the timelines. We now have: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P] [S] <------> [T] + // + // 4. Now we add event R to timeline2: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] [S] <------> [T] + // + // Note that we have switched the timeline we are working on from + // timeline1 to timeline2. + // + // 5. We ignore event S, but again join the timelines: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] <---> [S] <------> [T] + // + // 6. We ignore event T, and the timelines are already joined, so there + // is nothing to do. + // + // 7. Finally, we add event U to timeline4: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] <---> [S] <------> [T, U] + // + // The important thing to note in the above is what happened when we + // already knew about a given event: + // + // - if it was appropriate, we joined up the timelines (steps 3, 5). + // - in any case, we started adding further events to the timeline which + // contained the event we knew about (steps 3, 5, 6). + // + // + // So much for adding events to the timeline. But what do we want to do + // with the pagination token? + // + // In the case above, we will be given a pagination token which tells us how to + // get events beyond 'U' - in this case, it makes sense to store this + // against timeline4. But what if timeline4 already had 'U' and beyond? in + // that case, our best bet is to throw away the pagination token we were + // given and stick with whatever token timeline4 had previously. In short, + // we want to only store the pagination token if the last event we receive + // is one we didn't previously know about. + // + // We make an exception for this if it turns out that we already knew about + // *all* of the events, and we weren't able to join up any timelines. When + // that happens, it means our existing pagination token is faulty, since it + // is only telling us what we already know. Rather than repeatedly + // paginating with the same token, we might as well use the new pagination + // token in the hope that we eventually work our way out of the mess. + + let didUpdate = false; + let lastEventWasNew = false; + for (let i = 0; i < events.length; i++) { + const event = events[i]; + const eventId = event.getId(); + + const existingTimeline = this._eventIdToTimeline[eventId]; + + if (!existingTimeline) { + // we don't know about this event yet. Just add it to the timeline. + this.addEventToTimeline(event, timeline, toStartOfTimeline); + lastEventWasNew = true; + didUpdate = true; + continue; + } + + lastEventWasNew = false; + + if (existingTimeline == timeline) { + debuglog("Event " + eventId + " already in timeline " + timeline); + continue; + } + + const neighbour = timeline.getNeighbouringTimeline(direction); + if (neighbour) { + // this timeline already has a neighbour in the relevant direction; + // let's assume the timelines are already correctly linked up, and + // skip over to it. + // + // there's probably some edge-case here where we end up with an + // event which is in a timeline a way down the chain, and there is + // a break in the chain somewhere. But I can't really imagine how + // that would happen, so I'm going to ignore it for now. + // + if (existingTimeline == neighbour) { + debuglog("Event " + eventId + " in neighbouring timeline - " + + "switching to " + existingTimeline); + } else { + debuglog("Event " + eventId + " already in a different " + + "timeline " + existingTimeline); + } + timeline = existingTimeline; + continue; + } + + // time to join the timelines. + logger.info("Already have timeline for " + eventId + + " - joining timeline " + timeline + " to " + + existingTimeline); + + // Variables to keep the line length limited below. + const existingIsLive = existingTimeline === this.liveTimeline; + const timelineIsLive = timeline === this.liveTimeline; + + const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive; + const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive; + + if (backwardsIsLive || forwardsIsLive) { + // The live timeline should never be spliced into a non-live position. + // We use independent logging to better discover the problem at a glance. + if (backwardsIsLive) { + logger.warn( + "Refusing to set a preceding existingTimeLine on our " + + "timeline as the existingTimeLine is live (" + existingTimeline + ")", + ); + } + if (forwardsIsLive) { + logger.warn( + "Refusing to set our preceding timeline on a existingTimeLine " + + "as our timeline is live (" + timeline + ")", + ); + } + continue; // abort splicing - try next event + } + + timeline.setNeighbouringTimeline(existingTimeline, direction); + existingTimeline.setNeighbouringTimeline(timeline, inverseDirection); + + timeline = existingTimeline; + didUpdate = true; + } + + // see above - if the last event was new to us, or if we didn't find any + // new information, we update the pagination token for whatever + // timeline we ended up on. + if (lastEventWasNew || !didUpdate) { + if (direction === EventTimeline.FORWARDS && timeline === this.liveTimeline) { + logger.warn({ lastEventWasNew, didUpdate }); // for debugging + logger.warn( + `Refusing to set forwards pagination token of live timeline ` + + `${timeline} to ${paginationToken}`, + ); + return; + } + timeline.setPaginationToken(paginationToken, direction); + } + } + + /** + * Add an event to the end of this live timeline. + * + * @param {MatrixEvent} event Event to be added + * @param {string?} duplicateStrategy 'ignore' or 'replace' + * @param {boolean} fromCache whether the sync response came from cache + */ + public addLiveEvent(event: MatrixEvent, duplicateStrategy?: "ignore" | "replace", fromCache = false): void { + if (this.filter) { + const events = this.filter.filterRoomTimeline([event]); + if (!events.length) { + return; + } + } + + const timeline = this._eventIdToTimeline[event.getId()]; + if (timeline) { + if (duplicateStrategy === "replace") { + debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + + event.getId()); + const tlEvents = timeline.getEvents(); + for (let j = 0; j < tlEvents.length; j++) { + if (tlEvents[j].getId() === event.getId()) { + // still need to set the right metadata on this event + EventTimeline.setEventMetadata( + event, + timeline.getState(EventTimeline.FORWARDS), + false, + ); + tlEvents[j] = event; + + // XXX: we need to fire an event when this happens. + break; + } + } + } else { + debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + + event.getId()); + } + return; + } + + this.addEventToTimeline(event, this.liveTimeline, false, fromCache); + } + + /** + * Add event to the given timeline, and emit Room.timeline. Assumes + * we have already checked we don't know about this event. + * + * Will fire "Room.timeline" for each event added. + * + * @param {MatrixEvent} event + * @param {EventTimeline} timeline + * @param {boolean} toStartOfTimeline + * @param {boolean} fromCache whether the sync response came from cache + * + * @fires module:client~MatrixClient#event:"Room.timeline" + */ + public addEventToTimeline( + event: MatrixEvent, + timeline: EventTimeline, + toStartOfTimeline: boolean, + fromCache = false, + ) { + const eventId = event.getId(); + timeline.addEvent(event, toStartOfTimeline); + this._eventIdToTimeline[eventId] = timeline; + + this.setRelationsTarget(event); + this.aggregateRelations(event); + + const data = { + timeline: timeline, + liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache, + }; + this.emit("Room.timeline", event, this.room, + Boolean(toStartOfTimeline), false, data); + } + + /** + * Replaces event with ID oldEventId with one with newEventId, if oldEventId is + * recognised. Otherwise, add to the live timeline. Used to handle remote echos. + * + * @param {MatrixEvent} localEvent the new event to be added to the timeline + * @param {String} oldEventId the ID of the original event + * @param {boolean} newEventId the ID of the replacement event + * + * @fires module:client~MatrixClient#event:"Room.timeline" + */ + public handleRemoteEcho( + localEvent: MatrixEvent, + oldEventId: string, + newEventId: string, + ): void { + // XXX: why don't we infer newEventId from localEvent? + const existingTimeline = this._eventIdToTimeline[oldEventId]; + if (existingTimeline) { + delete this._eventIdToTimeline[oldEventId]; + this._eventIdToTimeline[newEventId] = existingTimeline; + } else { + if (this.filter) { + if (this.filter.filterRoomTimeline([localEvent]).length) { + this.addEventToTimeline(localEvent, this.liveTimeline, false); + } + } else { + this.addEventToTimeline(localEvent, this.liveTimeline, false); + } + } + } + + /** + * Removes a single event from this room. + * + * @param {String} eventId The id of the event to remove + * + * @return {?MatrixEvent} the removed event, or null if the event was not found + * in this room. + */ + public removeEvent(eventId: string): MatrixEvent | null { + const timeline = this._eventIdToTimeline[eventId]; + if (!timeline) { + return null; + } + + const removed = timeline.removeEvent(eventId); + if (removed) { + delete this._eventIdToTimeline[eventId]; + const data = { + timeline: timeline, + }; + this.emit("Room.timeline", removed, this.room, undefined, true, data); + } + return removed; + } + + /** + * Determine where two events appear in the timeline relative to one another + * + * @param {string} eventId1 The id of the first event + * @param {string} eventId2 The id of the second event + + * @return {?number} a number less than zero if eventId1 precedes eventId2, and + * greater than zero if eventId1 succeeds eventId2. zero if they are the + * same event; null if we can't tell (either because we don't know about one + * of the events, or because they are in separate timelines which don't join + * up). + */ + public compareEventOrdering(eventId1: string, eventId2: string): number | null { + if (eventId1 == eventId2) { + // optimise this case + return 0; + } + + const timeline1 = this._eventIdToTimeline[eventId1]; + const timeline2 = this._eventIdToTimeline[eventId2]; + + if (timeline1 === undefined) { + return null; + } + if (timeline2 === undefined) { + return null; + } + + if (timeline1 === timeline2) { + // both events are in the same timeline - figure out their + // relative indices + let idx1; + let idx2; + const events = timeline1.getEvents(); + for (let idx = 0; idx < events.length && + (idx1 === undefined || idx2 === undefined); idx++) { + const evId = events[idx].getId(); + if (evId == eventId1) { + idx1 = idx; + } + if (evId == eventId2) { + idx2 = idx; + } + } + return idx1 - idx2; + } + + // the events are in different timelines. Iterate through the + // linkedlist to see which comes first. + + // first work forwards from timeline1 + let tl = timeline1; + while (tl) { + if (tl === timeline2) { + // timeline1 is before timeline2 + return -1; + } + tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS); + } + + // now try backwards from timeline1 + tl = timeline1; + while (tl) { + if (tl === timeline2) { + // timeline2 is before timeline1 + return 1; + } + tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS); + } + + // the timelines are not contiguous. + return null; + } + + /** + * Get a collection of relations to a given event in this timeline set. + * + * @param {String} eventId + * The ID of the event that you'd like to access relation events for. + * For example, with annotations, this would be the ID of the event being annotated. + * @param {String} relationType + * The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc. + * @param {String} eventType + * The relation event's type, such as "m.reaction", etc. + * @throws If eventId, relationType or eventType + * are not valid. + * + * @returns {?Relations} + * A container for relation events or undefined if there are no relation events for + * the relationType. + */ + public getRelationsForEvent( + eventId: string, + relationType: RelationType, + eventType: EventType | string, + ): Relations | undefined { + if (!this.unstableClientRelationAggregation) { + throw new Error("Client-side relation aggregation is disabled"); + } + + if (!eventId || !relationType || !eventType) { + throw new Error("Invalid arguments for `getRelationsForEvent`"); + } + + // debuglog("Getting relations for: ", eventId, relationType, eventType); + + const relationsForEvent = this.relations[eventId] || {}; + const relationsWithRelType = relationsForEvent[relationType] || {}; + return relationsWithRelType[eventType]; + } + + /** + * Set an event as the target event if any Relations exist for it already + * + * @param {MatrixEvent} event + * The event to check as relation target. + */ + public setRelationsTarget(event: MatrixEvent): void { + if (!this.unstableClientRelationAggregation) { + return; + } + + const relationsForEvent = this.relations[event.getId()]; + if (!relationsForEvent) { + return; + } + + for (const relationsWithRelType of Object.values(relationsForEvent)) { + for (const relationsWithEventType of Object.values(relationsWithRelType)) { + relationsWithEventType.setTargetEvent(event); + } + } + } + + /** + * Add relation events to the relevant relation collection. + * + * @param {MatrixEvent} event + * The new relation event to be aggregated. + */ + public aggregateRelations(event: MatrixEvent): void { + if (!this.unstableClientRelationAggregation) { + return; + } + + if (event.isRedacted() || event.status === EventStatus.CANCELLED) { + return; + } + + // If the event is currently encrypted, wait until it has been decrypted. + if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { + event.once("Event.decrypted", () => { + this.aggregateRelations(event); + }); + return; + } + + const relation = event.getRelation(); + if (!relation) { + return; + } + + const relatesToEventId = relation.event_id; + const relationType = relation.rel_type; + const eventType = event.getType(); + + // debuglog("Aggregating relation: ", event.getId(), eventType, relation); + + let relationsForEvent: Record>> = this.relations[relatesToEventId]; + if (!relationsForEvent) { + relationsForEvent = this.relations[relatesToEventId] = {}; + } + let relationsWithRelType = relationsForEvent[relationType]; + if (!relationsWithRelType) { + relationsWithRelType = relationsForEvent[relationType] = {}; + } + let relationsWithEventType = relationsWithRelType[eventType]; + + let relatesToEvent; + if (!relationsWithEventType) { + relationsWithEventType = relationsWithRelType[eventType] = new Relations( + relationType, + eventType, + this.room, + ); + relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId); + if (relatesToEvent) { + relationsWithEventType.setTargetEvent(relatesToEvent); + } + } + + relationsWithEventType.addEvent(event); + } +} + +/** + * Fires whenever the timeline in a room is updated. + * @event module:client~MatrixClient#"Room.timeline" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {?Room} room The room, if any, whose timeline was updated. + * @param {boolean} toStartOfTimeline True if this event was added to the start + * @param {boolean} removed True if this event has just been removed from the timeline + * (beginning; oldest) of the timeline e.g. due to pagination. + * + * @param {object} data more data about the event + * + * @param {module:models/event-timeline.EventTimeline} data.timeline the timeline the + * event was added to/removed from + * + * @param {boolean} data.liveEvent true if the event was a real-time event + * added to the end of the live timeline + * + * @example + * matrixClient.on("Room.timeline", + * function(event, room, toStartOfTimeline, removed, data) { + * if (!toStartOfTimeline && data.liveEvent) { + * var messageToAppend = room.timeline.[room.timeline.length - 1]; + * } + * }); + */ + +/** + * Fires whenever the live timeline in a room is reset. + * + * When we get a 'limited' sync (for example, after a network outage), we reset + * the live timeline to be empty before adding the recent events to the new + * timeline. This event is fired after the timeline is reset, and before the + * new events are added. + * + * @event module:client~MatrixClient#"Room.timelineReset" + * @param {Room} room The room whose live timeline was reset, if any + * @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset + * @param {boolean} resetAllTimelines True if all timelines were reset. + */ diff --git a/src/models/event-timeline.js b/src/models/event-timeline.js deleted file mode 100644 index 288659611..000000000 --- a/src/models/event-timeline.js +++ /dev/null @@ -1,398 +0,0 @@ -/* -Copyright 2016, 2017 OpenMarket Ltd -Copyright 2019 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 models/event-timeline - */ - -import { RoomState } from "./room-state"; - -/** - * Construct a new EventTimeline - * - *

An EventTimeline represents a contiguous sequence of events in a room. - * - *

As well as keeping track of the events themselves, it stores the state of - * the room at the beginning and end of the timeline, and pagination tokens for - * going backwards and forwards in the timeline. - * - *

In order that clients can meaningfully maintain an index into a timeline, - * the EventTimeline object tracks a 'baseIndex'. This starts at zero, but is - * incremented when events are prepended to the timeline. The index of an event - * relative to baseIndex therefore remains constant. - * - *

Once a timeline joins up with its neighbour, they are linked together into a - * doubly-linked list. - * - * @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of - * @constructor - */ -export function EventTimeline(eventTimelineSet) { - this._eventTimelineSet = eventTimelineSet; - this._roomId = eventTimelineSet.room ? eventTimelineSet.room.roomId : null; - this._events = []; - this._baseIndex = 0; - this._startState = new RoomState(this._roomId); - this._startState.paginationToken = null; - this._endState = new RoomState(this._roomId); - this._endState.paginationToken = null; - - this._prevTimeline = null; - this._nextTimeline = null; - - // this is used by client.js - this._paginationRequests = { 'b': null, 'f': null }; - - this._name = this._roomId + ":" + new Date().toISOString(); -} - -/** - * Symbolic constant for methods which take a 'direction' argument: - * refers to the start of the timeline, or backwards in time. - */ -EventTimeline.BACKWARDS = "b"; - -/** - * Symbolic constant for methods which take a 'direction' argument: - * refers to the end of the timeline, or forwards in time. - */ -EventTimeline.FORWARDS = "f"; - -/** - * Initialise the start and end state with the given events - * - *

This can only be called before any events are added. - * - * @param {MatrixEvent[]} stateEvents list of state events to initialise the - * state with. - * @throws {Error} if an attempt is made to call this after addEvent is called. - */ -EventTimeline.prototype.initialiseState = function(stateEvents) { - if (this._events.length > 0) { - throw new Error("Cannot initialise state after events are added"); - } - - // We previously deep copied events here and used different copies in - // the oldState and state events: this decision seems to date back - // quite a way and was apparently made to fix a bug where modifications - // made to the start state leaked through to the end state. - // This really shouldn't be possible though: the events themselves should - // not change. Duplicating the events uses a lot of extra memory, - // so we now no longer do it. To assert that they really do never change, - // freeze them! Note that we can't do this for events in general: - // although it looks like the only things preventing us are the - // 'status' flag, forwardLooking (which is only set once when adding to the - // timeline) and possibly the sender (which seems like it should never be - // reset but in practice causes a lot of the tests to break). - for (const e of stateEvents) { - Object.freeze(e); - } - - this._startState.setStateEvents(stateEvents); - this._endState.setStateEvents(stateEvents); -}; - -/** - * Forks the (live) timeline, taking ownership of the existing directional state of this timeline. - * All attached listeners will keep receiving state updates from the new live timeline state. - * The end state of this timeline gets replaced with an independent copy of the current RoomState, - * and will need a new pagination token if it ever needs to paginate forwards. - - * @param {string} direction EventTimeline.BACKWARDS to get the state at the - * start of the timeline; EventTimeline.FORWARDS to get the state at the end - * of the timeline. - * - * @return {EventTimeline} the new timeline - */ -EventTimeline.prototype.forkLive = function(direction) { - const forkState = this.getState(direction); - const timeline = new EventTimeline(this._eventTimelineSet); - timeline._startState = forkState.clone(); - // Now clobber the end state of the new live timeline with that from the - // previous live timeline. It will be identical except that we'll keep - // using the same RoomMember objects for the 'live' set of members with any - // listeners still attached - timeline._endState = forkState; - // Firstly, we just stole the current timeline's end state, so it needs a new one. - // Make an immutable copy of the state so back pagination will get the correct sentinels. - this._endState = forkState.clone(); - return timeline; -}; - -/** - * Creates an independent timeline, inheriting the directional state from this timeline. - * - * @param {string} direction EventTimeline.BACKWARDS to get the state at the - * start of the timeline; EventTimeline.FORWARDS to get the state at the end - * of the timeline. - * - * @return {EventTimeline} the new timeline - */ -EventTimeline.prototype.fork = function(direction) { - const forkState = this.getState(direction); - const timeline = new EventTimeline(this._eventTimelineSet); - timeline._startState = forkState.clone(); - timeline._endState = forkState.clone(); - return timeline; -}; - -/** - * Get the ID of the room for this timeline - * @return {string} room ID - */ -EventTimeline.prototype.getRoomId = function() { - return this._roomId; -}; - -/** - * Get the filter for this timeline's timelineSet (if any) - * @return {Filter} filter - */ -EventTimeline.prototype.getFilter = function() { - return this._eventTimelineSet.getFilter(); -}; - -/** - * Get the timelineSet for this timeline - * @return {EventTimelineSet} timelineSet - */ -EventTimeline.prototype.getTimelineSet = function() { - return this._eventTimelineSet; -}; - -/** - * Get the base index. - * - *

This is an index which is incremented when events are prepended to the - * timeline. An individual event therefore stays at the same index in the array - * relative to the base index (although note that a given event's index may - * well be less than the base index, thus giving that event a negative relative - * index). - * - * @return {number} - */ -EventTimeline.prototype.getBaseIndex = function() { - return this._baseIndex; -}; - -/** - * Get the list of events in this context - * - * @return {MatrixEvent[]} An array of MatrixEvents - */ -EventTimeline.prototype.getEvents = function() { - return this._events; -}; - -/** - * Get the room state at the start/end of the timeline - * - * @param {string} direction EventTimeline.BACKWARDS to get the state at the - * start of the timeline; EventTimeline.FORWARDS to get the state at the end - * of the timeline. - * - * @return {RoomState} state at the start/end of the timeline - */ -EventTimeline.prototype.getState = function(direction) { - if (direction == EventTimeline.BACKWARDS) { - return this._startState; - } else if (direction == EventTimeline.FORWARDS) { - return this._endState; - } else { - throw new Error("Invalid direction '" + direction + "'"); - } -}; - -/** - * Get a pagination token - * - * @param {string} direction EventTimeline.BACKWARDS to get the pagination - * token for going backwards in time; EventTimeline.FORWARDS to get the - * pagination token for going forwards in time. - * - * @return {?string} pagination token - */ -EventTimeline.prototype.getPaginationToken = function(direction) { - return this.getState(direction).paginationToken; -}; - -/** - * Set a pagination token - * - * @param {?string} token pagination token - * - * @param {string} direction EventTimeline.BACKWARDS to set the pagination - * token for going backwards in time; EventTimeline.FORWARDS to set the - * pagination token for going forwards in time. - */ -EventTimeline.prototype.setPaginationToken = function(token, direction) { - this.getState(direction).paginationToken = token; -}; - -/** - * Get the next timeline in the series - * - * @param {string} direction EventTimeline.BACKWARDS to get the previous - * timeline; EventTimeline.FORWARDS to get the next timeline. - * - * @return {?EventTimeline} previous or following timeline, if they have been - * joined up. - */ -EventTimeline.prototype.getNeighbouringTimeline = function(direction) { - if (direction == EventTimeline.BACKWARDS) { - return this._prevTimeline; - } else if (direction == EventTimeline.FORWARDS) { - return this._nextTimeline; - } else { - throw new Error("Invalid direction '" + direction + "'"); - } -}; - -/** - * Set the next timeline in the series - * - * @param {EventTimeline} neighbour previous/following timeline - * - * @param {string} direction EventTimeline.BACKWARDS to set the previous - * timeline; EventTimeline.FORWARDS to set the next timeline. - * - * @throws {Error} if an attempt is made to set the neighbouring timeline when - * it is already set. - */ -EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) { - if (this.getNeighbouringTimeline(direction)) { - throw new Error("timeline already has a neighbouring timeline - " + - "cannot reset neighbour (direction: " + direction + ")"); - } - - if (direction == EventTimeline.BACKWARDS) { - this._prevTimeline = neighbour; - } else if (direction == EventTimeline.FORWARDS) { - this._nextTimeline = neighbour; - } else { - throw new Error("Invalid direction '" + direction + "'"); - } - - // make sure we don't try to paginate this timeline - this.setPaginationToken(null, direction); -}; - -/** - * Add a new event to the timeline, and update the state - * - * @param {MatrixEvent} event new event - * @param {boolean} atStart true to insert new event at the start - */ -EventTimeline.prototype.addEvent = function(event, atStart) { - const stateContext = atStart ? this._startState : this._endState; - - // only call setEventMetadata on the unfiltered timelineSets - const timelineSet = this.getTimelineSet(); - if (timelineSet.room && - timelineSet.room.getUnfilteredTimelineSet() === timelineSet) { - EventTimeline.setEventMetadata(event, stateContext, atStart); - - // modify state - if (event.isState()) { - stateContext.setStateEvents([event]); - // it is possible that the act of setting the state event means we - // can set more metadata (specifically sender/target props), so try - // it again if the prop wasn't previously set. It may also mean that - // the sender/target is updated (if the event set was a room member event) - // so we want to use the *updated* member (new avatar/name) instead. - // - // However, we do NOT want to do this on member events if we're going - // back in time, else we'll set the .sender value for BEFORE the given - // member event, whereas we want to set the .sender value for the ACTUAL - // member event itself. - if (!event.sender || (event.getType() === "m.room.member" && !atStart)) { - EventTimeline.setEventMetadata(event, stateContext, atStart); - } - } - } - - let insertIndex; - - if (atStart) { - insertIndex = 0; - } else { - insertIndex = this._events.length; - } - - this._events.splice(insertIndex, 0, event); // insert element - if (atStart) { - this._baseIndex++; - } -}; - -/** - * Static helper method to set sender and target properties - * - * @param {MatrixEvent} event the event whose metadata is to be set - * @param {RoomState} stateContext the room state to be queried - * @param {bool} toStartOfTimeline if true the event's forwardLooking flag is set false - */ -EventTimeline.setEventMetadata = function(event, stateContext, toStartOfTimeline) { - // set sender and target properties - event.sender = stateContext.getSentinelMember( - event.getSender(), - ); - if (event.getType() === "m.room.member") { - event.target = stateContext.getSentinelMember( - event.getStateKey(), - ); - } - if (event.isState()) { - // room state has no concept of 'old' or 'current', but we want the - // room state to regress back to previous values if toStartOfTimeline - // is set, which means inspecting prev_content if it exists. This - // is done by toggling the forwardLooking flag. - if (toStartOfTimeline) { - event.forwardLooking = false; - } - } -}; - -/** - * Remove an event from the timeline - * - * @param {string} eventId ID of event to be removed - * @return {?MatrixEvent} removed event, or null if not found - */ -EventTimeline.prototype.removeEvent = function(eventId) { - for (let i = this._events.length - 1; i >= 0; i--) { - const ev = this._events[i]; - if (ev.getId() == eventId) { - this._events.splice(i, 1); - if (i < this._baseIndex) { - this._baseIndex--; - } - return ev; - } - } - return null; -}; - -/** - * Return a string to identify this timeline, for debugging - * - * @return {string} name for this timeline - */ -EventTimeline.prototype.toString = function() { - return this._name; -}; - diff --git a/src/models/event-timeline.ts b/src/models/event-timeline.ts new file mode 100644 index 000000000..92a9b9633 --- /dev/null +++ b/src/models/event-timeline.ts @@ -0,0 +1,416 @@ +/* +Copyright 2016 - 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 models/event-timeline + */ + +import { RoomState } from "./room-state"; +import { EventTimelineSet } from "./event-timeline-set"; +import { MatrixEvent } from "./event"; +import { Filter } from "../filter"; + +export enum Direction { + Backward = "b", + Forward = "f", +} + +export class EventTimeline { + /** + * Symbolic constant for methods which take a 'direction' argument: + * refers to the start of the timeline, or backwards in time. + */ + static BACKWARDS = Direction.Backward; + + /** + * Symbolic constant for methods which take a 'direction' argument: + * refers to the end of the timeline, or forwards in time. + */ + static FORWARDS = Direction.Forward; + + /** + * Static helper method to set sender and target properties + * + * @param {MatrixEvent} event the event whose metadata is to be set + * @param {RoomState} stateContext the room state to be queried + * @param {boolean} toStartOfTimeline if true the event's forwardLooking flag is set false + */ + static setEventMetadata(event: MatrixEvent, stateContext: RoomState, toStartOfTimeline: boolean): void { + // set sender and target properties + event.sender = stateContext.getSentinelMember( + event.getSender(), + ); + if (event.getType() === "m.room.member") { + event.target = stateContext.getSentinelMember( + event.getStateKey(), + ); + } + if (event.isState()) { + // room state has no concept of 'old' or 'current', but we want the + // room state to regress back to previous values if toStartOfTimeline + // is set, which means inspecting prev_content if it exists. This + // is done by toggling the forwardLooking flag. + if (toStartOfTimeline) { + event.forwardLooking = false; + } + } + } + + private readonly roomId: string | null; + private readonly name: string; + private events: MatrixEvent[] = []; + private baseIndex = 0; + private startState: RoomState; + private endState: RoomState; + private prevTimeline?: EventTimeline; + private nextTimeline?: EventTimeline; + public paginationRequests: Record> = { + [Direction.Backward]: null, + [Direction.Forward]: null, + }; + + /** + * Construct a new EventTimeline + * + *

An EventTimeline represents a contiguous sequence of events in a room. + * + *

As well as keeping track of the events themselves, it stores the state of + * the room at the beginning and end of the timeline, and pagination tokens for + * going backwards and forwards in the timeline. + * + *

In order that clients can meaningfully maintain an index into a timeline, + * the EventTimeline object tracks a 'baseIndex'. This starts at zero, but is + * incremented when events are prepended to the timeline. The index of an event + * relative to baseIndex therefore remains constant. + * + *

Once a timeline joins up with its neighbour, they are linked together into a + * doubly-linked list. + * + * @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of + * @constructor + */ + constructor(private readonly eventTimelineSet: EventTimelineSet) { + this.roomId = eventTimelineSet.room?.roomId ?? null; + this.startState = new RoomState(this.roomId); + this.startState.paginationToken = null; + this.endState = new RoomState(this.roomId); + this.endState.paginationToken = null; + + this.prevTimeline = null; + this.nextTimeline = null; + + // this is used by client.js + this.paginationRequests = { 'b': null, 'f': null }; + + this.name = this.roomId + ":" + new Date().toISOString(); + } + + /** + * Initialise the start and end state with the given events + * + *

This can only be called before any events are added. + * + * @param {MatrixEvent[]} stateEvents list of state events to initialise the + * state with. + * @throws {Error} if an attempt is made to call this after addEvent is called. + */ + public initialiseState(stateEvents: MatrixEvent[]): void { + if (this.events.length > 0) { + throw new Error("Cannot initialise state after events are added"); + } + + // We previously deep copied events here and used different copies in + // the oldState and state events: this decision seems to date back + // quite a way and was apparently made to fix a bug where modifications + // made to the start state leaked through to the end state. + // This really shouldn't be possible though: the events themselves should + // not change. Duplicating the events uses a lot of extra memory, + // so we now no longer do it. To assert that they really do never change, + // freeze them! Note that we can't do this for events in general: + // although it looks like the only things preventing us are the + // 'status' flag, forwardLooking (which is only set once when adding to the + // timeline) and possibly the sender (which seems like it should never be + // reset but in practice causes a lot of the tests to break). + for (const e of stateEvents) { + Object.freeze(e); + } + + this.startState.setStateEvents(stateEvents); + this.endState.setStateEvents(stateEvents); + } + + /** + * Forks the (live) timeline, taking ownership of the existing directional state of this timeline. + * All attached listeners will keep receiving state updates from the new live timeline state. + * The end state of this timeline gets replaced with an independent copy of the current RoomState, + * and will need a new pagination token if it ever needs to paginate forwards. + + * @param {string} direction EventTimeline.BACKWARDS to get the state at the + * start of the timeline; EventTimeline.FORWARDS to get the state at the end + * of the timeline. + * + * @return {EventTimeline} the new timeline + */ + public forkLive(direction: Direction): EventTimeline { + const forkState = this.getState(direction); + const timeline = new EventTimeline(this.eventTimelineSet); + timeline.startState = forkState.clone(); + // Now clobber the end state of the new live timeline with that from the + // previous live timeline. It will be identical except that we'll keep + // using the same RoomMember objects for the 'live' set of members with any + // listeners still attached + timeline.endState = forkState; + // Firstly, we just stole the current timeline's end state, so it needs a new one. + // Make an immutable copy of the state so back pagination will get the correct sentinels. + this.endState = forkState.clone(); + return timeline; + } + + /** + * Creates an independent timeline, inheriting the directional state from this timeline. + * + * @param {string} direction EventTimeline.BACKWARDS to get the state at the + * start of the timeline; EventTimeline.FORWARDS to get the state at the end + * of the timeline. + * + * @return {EventTimeline} the new timeline + */ + public fork(direction: Direction): EventTimeline { + const forkState = this.getState(direction); + const timeline = new EventTimeline(this.eventTimelineSet); + timeline.startState = forkState.clone(); + timeline.endState = forkState.clone(); + return timeline; + } + + /** + * Get the ID of the room for this timeline + * @return {string} room ID + */ + public getRoomId(): string { + return this.roomId; + } + + /** + * Get the filter for this timeline's timelineSet (if any) + * @return {Filter} filter + */ + public getFilter(): Filter { + return this.eventTimelineSet.getFilter(); + } + + /** + * Get the timelineSet for this timeline + * @return {EventTimelineSet} timelineSet + */ + public getTimelineSet(): EventTimelineSet { + return this.eventTimelineSet; + } + + /** + * Get the base index. + * + *

This is an index which is incremented when events are prepended to the + * timeline. An individual event therefore stays at the same index in the array + * relative to the base index (although note that a given event's index may + * well be less than the base index, thus giving that event a negative relative + * index). + * + * @return {number} + */ + public getBaseIndex(): number { + return this.baseIndex; + } + + /** + * Get the list of events in this context + * + * @return {MatrixEvent[]} An array of MatrixEvents + */ + public getEvents(): MatrixEvent[] { + return this.events; + } + + /** + * Get the room state at the start/end of the timeline + * + * @param {string} direction EventTimeline.BACKWARDS to get the state at the + * start of the timeline; EventTimeline.FORWARDS to get the state at the end + * of the timeline. + * + * @return {RoomState} state at the start/end of the timeline + */ + public getState(direction: Direction): RoomState { + if (direction == EventTimeline.BACKWARDS) { + return this.startState; + } else if (direction == EventTimeline.FORWARDS) { + return this.endState; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + } + + /** + * Get a pagination token + * + * @param {string} direction EventTimeline.BACKWARDS to get the pagination + * token for going backwards in time; EventTimeline.FORWARDS to get the + * pagination token for going forwards in time. + * + * @return {?string} pagination token + */ + public getPaginationToken(direction: Direction): string | null { + return this.getState(direction).paginationToken; + } + + /** + * Set a pagination token + * + * @param {?string} token pagination token + * + * @param {string} direction EventTimeline.BACKWARDS to set the pagination + * token for going backwards in time; EventTimeline.FORWARDS to set the + * pagination token for going forwards in time. + */ + public setPaginationToken(token: string, direction: Direction): void { + this.getState(direction).paginationToken = token; + } + + /** + * Get the next timeline in the series + * + * @param {string} direction EventTimeline.BACKWARDS to get the previous + * timeline; EventTimeline.FORWARDS to get the next timeline. + * + * @return {?EventTimeline} previous or following timeline, if they have been + * joined up. + */ + public getNeighbouringTimeline(direction: Direction): EventTimeline { + if (direction == EventTimeline.BACKWARDS) { + return this.prevTimeline; + } else if (direction == EventTimeline.FORWARDS) { + return this.nextTimeline; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + } + + /** + * Set the next timeline in the series + * + * @param {EventTimeline} neighbour previous/following timeline + * + * @param {string} direction EventTimeline.BACKWARDS to set the previous + * timeline; EventTimeline.FORWARDS to set the next timeline. + * + * @throws {Error} if an attempt is made to set the neighbouring timeline when + * it is already set. + */ + public setNeighbouringTimeline(neighbour: EventTimeline, direction: Direction): void { + if (this.getNeighbouringTimeline(direction)) { + throw new Error("timeline already has a neighbouring timeline - " + + "cannot reset neighbour (direction: " + direction + ")"); + } + + if (direction == EventTimeline.BACKWARDS) { + this.prevTimeline = neighbour; + } else if (direction == EventTimeline.FORWARDS) { + this.nextTimeline = neighbour; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + + // make sure we don't try to paginate this timeline + this.setPaginationToken(null, direction); + } + + /** + * Add a new event to the timeline, and update the state + * + * @param {MatrixEvent} event new event + * @param {boolean} atStart true to insert new event at the start + */ + public addEvent(event: MatrixEvent, atStart: boolean): void { + const stateContext = atStart ? this.startState : this.endState; + + // only call setEventMetadata on the unfiltered timelineSets + const timelineSet = this.getTimelineSet(); + if (timelineSet.room && + timelineSet.room.getUnfilteredTimelineSet() === timelineSet) { + EventTimeline.setEventMetadata(event, stateContext, atStart); + + // modify state + if (event.isState()) { + stateContext.setStateEvents([event]); + // it is possible that the act of setting the state event means we + // can set more metadata (specifically sender/target props), so try + // it again if the prop wasn't previously set. It may also mean that + // the sender/target is updated (if the event set was a room member event) + // so we want to use the *updated* member (new avatar/name) instead. + // + // However, we do NOT want to do this on member events if we're going + // back in time, else we'll set the .sender value for BEFORE the given + // member event, whereas we want to set the .sender value for the ACTUAL + // member event itself. + if (!event.sender || (event.getType() === "m.room.member" && !atStart)) { + EventTimeline.setEventMetadata(event, stateContext, atStart); + } + } + } + + let insertIndex; + + if (atStart) { + insertIndex = 0; + } else { + insertIndex = this.events.length; + } + + this.events.splice(insertIndex, 0, event); // insert element + if (atStart) { + this.baseIndex++; + } + } + + /** + * Remove an event from the timeline + * + * @param {string} eventId ID of event to be removed + * @return {?MatrixEvent} removed event, or null if not found + */ + public removeEvent(eventId: string): MatrixEvent | null { + for (let i = this.events.length - 1; i >= 0; i--) { + const ev = this.events[i]; + if (ev.getId() == eventId) { + this.events.splice(i, 1); + if (i < this.baseIndex) { + this.baseIndex--; + } + return ev; + } + } + return null; + } + + /** + * Return a string to identify this timeline, for debugging + * + * @return {string} name for this timeline + */ + public toString(): string { + return this.name; + } +} diff --git a/src/models/event.ts b/src/models/event.ts index 490988787..73648dca9 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -112,7 +112,7 @@ interface IAggregatedRelation { } interface IEventRelation { - rel_type: string; + rel_type: RelationType | string; event_id: string; key?: string; } diff --git a/src/models/relations.ts b/src/models/relations.ts index 288ef3616..37beeb31d 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -49,7 +49,7 @@ export class Relations extends EventEmitter { * notification timeline. */ constructor( - public readonly relationType: RelationType, + public readonly relationType: RelationType | string, public readonly eventType: string, private readonly room: Room, ) { diff --git a/src/service-types.js b/src/service-types.ts similarity index 72% rename from src/service-types.js rename to src/service-types.ts index 0803b9247..79dc99937 100644 --- a/src/service-types.js +++ b/src/service-types.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -export const SERVICE_TYPES = Object.freeze({ - IS: 'SERVICE_TYPE_IS', // An Identity Service - IM: 'SERVICE_TYPE_IM', // An Integration Manager -}); +export enum SERVICE_TYPES { + IS = 'SERVICE_TYPE_IS', // An Identity Service + IM = 'SERVICE_TYPE_IM', // An Integration Manager +} diff --git a/src/timeline-window.js b/src/timeline-window.js deleted file mode 100644 index 0fc9f3ae4..000000000 --- a/src/timeline-window.js +++ /dev/null @@ -1,521 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 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 timeline-window */ - -import { EventTimeline } from './models/event-timeline'; -import { logger } from './logger'; - -/** - * @private - */ -const DEBUG = false; - -/** - * @private - */ -const debuglog = DEBUG ? logger.log.bind(logger) : function() {}; - -/** - * the number of times we ask the server for more events before giving up - * - * @private - */ -const DEFAULT_PAGINATE_LOOP_LIMIT = 5; - -/** - * Construct a TimelineWindow. - * - *

This abstracts the separate timelines in a Matrix {@link - * module:models/room|Room} into a single iterable thing. It keeps track of - * the start and endpoints of the window, which can be advanced with the help - * of pagination requests. - * - *

Before the window is useful, it must be initialised by calling {@link - * module:timeline-window~TimelineWindow#load|load}. - * - *

Note that the window will not automatically extend itself when new events - * are received from /sync; you should arrange to call {@link - * module:timeline-window~TimelineWindow#paginate|paginate} on {@link - * module:client~MatrixClient.event:"Room.timeline"|Room.timeline} events. - * - * @param {MatrixClient} client MatrixClient to be used for context/pagination - * requests. - * - * @param {EventTimelineSet} timelineSet The timelineSet to track - * - * @param {Object} [opts] Configuration options for this window - * - * @param {number} [opts.windowLimit = 1000] maximum number of events to keep - * in the window. If more events are retrieved via pagination requests, - * excess events will be dropped from the other end of the window. - * - * @constructor - */ -export function TimelineWindow(client, timelineSet, opts) { - opts = opts || {}; - this._client = client; - this._timelineSet = timelineSet; - - // these will be TimelineIndex objects; they delineate the 'start' and - // 'end' of the window. - // - // _start.index is inclusive; _end.index is exclusive. - this._start = null; - this._end = null; - - this._eventCount = 0; - this._windowLimit = opts.windowLimit || 1000; -} - -/** - * Initialise the window to point at a given event, or the live timeline - * - * @param {string} [initialEventId] If given, the window will contain the - * given event - * @param {number} [initialWindowSize = 20] Size of the initial window - * - * @return {Promise} - */ -TimelineWindow.prototype.load = function(initialEventId, initialWindowSize) { - const self = this; - initialWindowSize = initialWindowSize || 20; - - // given an EventTimeline, find the event we were looking for, and initialise our - // fields so that the event in question is in the middle of the window. - const initFields = function(timeline) { - let eventIndex; - - const events = timeline.getEvents(); - - if (!initialEventId) { - // we were looking for the live timeline: initialise to the end - eventIndex = events.length; - } else { - for (let i = 0; i < events.length; i++) { - if (events[i].getId() == initialEventId) { - eventIndex = i; - break; - } - } - - if (eventIndex === undefined) { - throw new Error("getEventTimeline result didn't include requested event"); - } - } - - const endIndex = Math.min(events.length, - eventIndex + Math.ceil(initialWindowSize / 2)); - const startIndex = Math.max(0, endIndex - initialWindowSize); - self._start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex()); - self._end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex()); - self._eventCount = endIndex - startIndex; - }; - - // We avoid delaying the resolution of the promise by a reactor tick if - // we already have the data we need, which is important to keep room-switching - // feeling snappy. - // - if (initialEventId) { - const timeline = this._timelineSet.getTimelineForEvent(initialEventId); - if (timeline) { - // hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does. - initFields(timeline); - return Promise.resolve(timeline); - } - - const prom = this._client.getEventTimeline(this._timelineSet, initialEventId); - return prom.then(initFields); - } else { - const tl = this._timelineSet.getLiveTimeline(); - initFields(tl); - return Promise.resolve(); - } -}; - -/** - * Get the TimelineIndex of the window in the given direction. - * - * @param {string} direction EventTimeline.BACKWARDS to get the TimelineIndex - * at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at - * the end. - * - * @return {TimelineIndex} The requested timeline index if one exists, null - * otherwise. - */ -TimelineWindow.prototype.getTimelineIndex = function(direction) { - if (direction == EventTimeline.BACKWARDS) { - return this._start; - } else if (direction == EventTimeline.FORWARDS) { - return this._end; - } else { - throw new Error("Invalid direction '" + direction + "'"); - } -}; - -/** - * Try to extend the window using events that are already in the underlying - * TimelineIndex. - * - * @param {string} direction EventTimeline.BACKWARDS to try extending it - * backwards; EventTimeline.FORWARDS to try extending it forwards. - * @param {number} size number of events to try to extend by. - * - * @return {boolean} true if the window was extended, false otherwise. - */ -TimelineWindow.prototype.extend = function(direction, size) { - const tl = this.getTimelineIndex(direction); - - if (!tl) { - debuglog("TimelineWindow: no timeline yet"); - return false; - } - - const count = (direction == EventTimeline.BACKWARDS) ? - tl.retreat(size) : tl.advance(size); - - if (count) { - this._eventCount += count; - debuglog("TimelineWindow: increased cap by " + count + - " (now " + this._eventCount + ")"); - // remove some events from the other end, if necessary - const excess = this._eventCount - this._windowLimit; - if (excess > 0) { - this.unpaginate(excess, direction != EventTimeline.BACKWARDS); - } - return true; - } - - return false; -}; - -/** - * Check if this window can be extended - * - *

This returns true if we either have more events, or if we have a - * pagination token which means we can paginate in that direction. It does not - * necessarily mean that there are more events available in that direction at - * this time. - * - * @param {string} direction EventTimeline.BACKWARDS to check if we can - * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards - * - * @return {boolean} true if we can paginate in the given direction - */ -TimelineWindow.prototype.canPaginate = function(direction) { - const tl = this.getTimelineIndex(direction); - - if (!tl) { - debuglog("TimelineWindow: no timeline yet"); - return false; - } - - if (direction == EventTimeline.BACKWARDS) { - if (tl.index > tl.minIndex()) { - return true; - } - } else { - if (tl.index < tl.maxIndex()) { - return true; - } - } - - return Boolean(tl.timeline.getNeighbouringTimeline(direction) || - tl.timeline.getPaginationToken(direction)); -}; - -/** - * Attempt to extend the window - * - * @param {string} direction EventTimeline.BACKWARDS to extend the window - * backwards (towards older events); EventTimeline.FORWARDS to go forwards. - * - * @param {number} size number of events to try to extend by. If fewer than this - * number are immediately available, then we return immediately rather than - * making an API call. - * - * @param {boolean} [makeRequest = true] whether we should make API calls to - * fetch further events if we don't have any at all. (This has no effect if - * the room already knows about additional events in the relevant direction, - * even if there are fewer than 'size' of them, as we will just return those - * we already know about.) - * - * @param {number} [requestLimit = 5] limit for the number of API requests we - * should make. - * - * @return {Promise} Resolves to a boolean which is true if more events - * were successfully retrieved. - */ -TimelineWindow.prototype.paginate = function(direction, size, makeRequest, - requestLimit) { - // Either wind back the message cap (if there are enough events in the - // timeline to do so), or fire off a pagination request. - - if (makeRequest === undefined) { - makeRequest = true; - } - - if (requestLimit === undefined) { - requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT; - } - - const tl = this.getTimelineIndex(direction); - - if (!tl) { - debuglog("TimelineWindow: no timeline yet"); - return Promise.resolve(false); - } - - if (tl.pendingPaginate) { - return tl.pendingPaginate; - } - - // try moving the cap - if (this.extend(direction, size)) { - return Promise.resolve(true); - } - - if (!makeRequest || requestLimit === 0) { - // todo: should we return something different to indicate that there - // might be more events out there, but we haven't found them yet? - return Promise.resolve(false); - } - - // try making a pagination request - const token = tl.timeline.getPaginationToken(direction); - if (!token) { - debuglog("TimelineWindow: no token"); - return Promise.resolve(false); - } - - debuglog("TimelineWindow: starting request"); - const self = this; - - const prom = this._client.paginateEventTimeline(tl.timeline, { - backwards: direction == EventTimeline.BACKWARDS, - limit: size, - }).finally(function() { - tl.pendingPaginate = null; - }).then(function(r) { - debuglog("TimelineWindow: request completed with result " + r); - if (!r) { - // end of timeline - return false; - } - - // recurse to advance the index into the results. - // - // If we don't get any new events, we want to make sure we keep asking - // the server for events for as long as we have a valid pagination - // token. In particular, we want to know if we've actually hit the - // start of the timeline, or if we just happened to know about all of - // the events thanks to https://matrix.org/jira/browse/SYN-645. - // - // On the other hand, we necessarily want to wait forever for the - // server to make its mind up about whether there are other events, - // because it gives a bad user experience - // (https://github.com/vector-im/vector-web/issues/1204). - return self.paginate(direction, size, true, requestLimit - 1); - }); - tl.pendingPaginate = prom; - return prom; -}; - -/** - * Remove `delta` events from the start or end of the timeline. - * - * @param {number} delta number of events to remove from the timeline - * @param {boolean} startOfTimeline if events should be removed from the start - * of the timeline. - */ -TimelineWindow.prototype.unpaginate = function(delta, startOfTimeline) { - const tl = startOfTimeline ? this._start : this._end; - - // sanity-check the delta - if (delta > this._eventCount || delta < 0) { - throw new Error("Attemting to unpaginate " + delta + " events, but " + - "only have " + this._eventCount + " in the timeline"); - } - - while (delta > 0) { - const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta); - if (count <= 0) { - // sadness. This shouldn't be possible. - throw new Error( - "Unable to unpaginate any further, but still have " + - this._eventCount + " events"); - } - - delta -= count; - this._eventCount -= count; - debuglog("TimelineWindow.unpaginate: dropped " + count + - " (now " + this._eventCount + ")"); - } -}; - -/** - * Get a list of the events currently in the window - * - * @return {MatrixEvent[]} the events in the window - */ -TimelineWindow.prototype.getEvents = function() { - if (!this._start) { - // not yet loaded - return []; - } - - const result = []; - - // iterate through each timeline between this._start and this._end - // (inclusive). - let timeline = this._start.timeline; - while (true) { - const events = timeline.getEvents(); - - // For the first timeline in the chain, we want to start at - // this._start.index. For the last timeline in the chain, we want to - // stop before this._end.index. Otherwise, we want to copy all of the - // events in the timeline. - // - // (Note that both this._start.index and this._end.index are relative - // to their respective timelines' BaseIndex). - // - let startIndex = 0; - let endIndex = events.length; - if (timeline === this._start.timeline) { - startIndex = this._start.index + timeline.getBaseIndex(); - } - if (timeline === this._end.timeline) { - endIndex = this._end.index + timeline.getBaseIndex(); - } - - for (let i = startIndex; i < endIndex; i++) { - result.push(events[i]); - } - - // if we're not done, iterate to the next timeline. - if (timeline === this._end.timeline) { - break; - } else { - timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS); - } - } - - return result; -}; - -/** - * a thing which contains a timeline reference, and an index into it. - * - * @constructor - * @param {EventTimeline} timeline - * @param {number} index - * @private - */ -export function TimelineIndex(timeline, index) { - this.timeline = timeline; - - // the indexes are relative to BaseIndex, so could well be negative. - this.index = index; -} - -/** - * @return {number} the minimum possible value for the index in the current - * timeline - */ -TimelineIndex.prototype.minIndex = function() { - return this.timeline.getBaseIndex() * -1; -}; - -/** - * @return {number} the maximum possible value for the index in the current - * timeline (exclusive - ie, it actually returns one more than the index - * of the last element). - */ -TimelineIndex.prototype.maxIndex = function() { - return this.timeline.getEvents().length - this.timeline.getBaseIndex(); -}; - -/** - * Try move the index forward, or into the neighbouring timeline - * - * @param {number} delta number of events to advance by - * @return {number} number of events successfully advanced by - */ -TimelineIndex.prototype.advance = function(delta) { - if (!delta) { - return 0; - } - - // first try moving the index in the current timeline. See if there is room - // to do so. - let cappedDelta; - if (delta < 0) { - // we want to wind the index backwards. - // - // (this.minIndex() - this.index) is a negative number whose magnitude - // is the amount of room we have to wind back the index in the current - // timeline. We cap delta to this quantity. - cappedDelta = Math.max(delta, this.minIndex() - this.index); - if (cappedDelta < 0) { - this.index += cappedDelta; - return cappedDelta; - } - } else { - // we want to wind the index forwards. - // - // (this.maxIndex() - this.index) is a (positive) number whose magnitude - // is the amount of room we have to wind forward the index in the current - // timeline. We cap delta to this quantity. - cappedDelta = Math.min(delta, this.maxIndex() - this.index); - if (cappedDelta > 0) { - this.index += cappedDelta; - return cappedDelta; - } - } - - // the index is already at the start/end of the current timeline. - // - // next see if there is a neighbouring timeline to switch to. - const neighbour = this.timeline.getNeighbouringTimeline( - delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS); - if (neighbour) { - this.timeline = neighbour; - if (delta < 0) { - this.index = this.maxIndex(); - } else { - this.index = this.minIndex(); - } - - debuglog("paginate: switched to new neighbour"); - - // recurse, using the next timeline - return this.advance(delta); - } - - return 0; -}; - -/** - * Try move the index backwards, or into the neighbouring timeline - * - * @param {number} delta number of events to retreat by - * @return {number} number of events successfully retreated by - */ -TimelineIndex.prototype.retreat = function(delta) { - return this.advance(delta * -1) * -1; -}; diff --git a/src/timeline-window.ts b/src/timeline-window.ts new file mode 100644 index 000000000..5c16b7213 --- /dev/null +++ b/src/timeline-window.ts @@ -0,0 +1,526 @@ +/* +Copyright 2016 - 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 timeline-window */ + +import { Direction, EventTimeline } from './models/event-timeline'; +import { logger } from './logger'; +import { MatrixClient } from "./client"; +import { EventTimelineSet } from "./models/event-timeline-set"; +import { MatrixEvent } from "./models/event"; + +/** + * @private + */ +const DEBUG = false; + +/** + * @private + */ +const debuglog = DEBUG ? logger.log.bind(logger) : function() {}; + +/** + * the number of times we ask the server for more events before giving up + * + * @private + */ +const DEFAULT_PAGINATE_LOOP_LIMIT = 5; + +interface IOpts { + windowLimit?: number; +} + +export class TimelineWindow { + private readonly windowLimit: number; + // these will be TimelineIndex objects; they delineate the 'start' and + // 'end' of the window. + // + // start.index is inclusive; end.index is exclusive. + private start?: TimelineIndex = null; + private end?: TimelineIndex = null; + private eventCount = 0; + + /** + * Construct a TimelineWindow. + * + *

This abstracts the separate timelines in a Matrix {@link + * module:models/room|Room} into a single iterable thing. It keeps track of + * the start and endpoints of the window, which can be advanced with the help + * of pagination requests. + * + *

Before the window is useful, it must be initialised by calling {@link + * module:timeline-window~TimelineWindow#load|load}. + * + *

Note that the window will not automatically extend itself when new events + * are received from /sync; you should arrange to call {@link + * module:timeline-window~TimelineWindow#paginate|paginate} on {@link + * module:client~MatrixClient.event:"Room.timeline"|Room.timeline} events. + * + * @param {MatrixClient} client MatrixClient to be used for context/pagination + * requests. + * + * @param {EventTimelineSet} timelineSet The timelineSet to track + * + * @param {Object} [opts] Configuration options for this window + * + * @param {number} [opts.windowLimit = 1000] maximum number of events to keep + * in the window. If more events are retrieved via pagination requests, + * excess events will be dropped from the other end of the window. + * + * @constructor + */ + constructor( + private readonly client: MatrixClient, + private readonly timelineSet: EventTimelineSet, + opts: IOpts = {}, + ) { + this.windowLimit = opts.windowLimit || 1000; + } + + /** + * Initialise the window to point at a given event, or the live timeline + * + * @param {string} [initialEventId] If given, the window will contain the + * given event + * @param {number} [initialWindowSize = 20] Size of the initial window + * + * @return {Promise} + */ + public load(initialEventId: string, initialWindowSize = 20): Promise { + // given an EventTimeline, find the event we were looking for, and initialise our + // fields so that the event in question is in the middle of the window. + const initFields = (timeline: EventTimeline) => { + let eventIndex; + + const events = timeline.getEvents(); + + if (!initialEventId) { + // we were looking for the live timeline: initialise to the end + eventIndex = events.length; + } else { + for (let i = 0; i < events.length; i++) { + if (events[i].getId() == initialEventId) { + eventIndex = i; + break; + } + } + + if (eventIndex === undefined) { + throw new Error("getEventTimeline result didn't include requested event"); + } + } + + const endIndex = Math.min(events.length, + eventIndex + Math.ceil(initialWindowSize / 2)); + const startIndex = Math.max(0, endIndex - initialWindowSize); + this.start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex()); + this.end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex()); + this.eventCount = endIndex - startIndex; + }; + + // We avoid delaying the resolution of the promise by a reactor tick if + // we already have the data we need, which is important to keep room-switching + // feeling snappy. + // + if (initialEventId) { + const timeline = this.timelineSet.getTimelineForEvent(initialEventId); + if (timeline) { + // hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does. + initFields(timeline); + return Promise.resolve(timeline); + } + + const prom = this.client.getEventTimeline(this.timelineSet, initialEventId); + return prom.then(initFields); + } else { + const tl = this.timelineSet.getLiveTimeline(); + initFields(tl); + return Promise.resolve(); + } + } + + /** + * Get the TimelineIndex of the window in the given direction. + * + * @param {string} direction EventTimeline.BACKWARDS to get the TimelineIndex + * at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at + * the end. + * + * @return {TimelineIndex} The requested timeline index if one exists, null + * otherwise. + */ + public getTimelineIndex(direction: Direction): TimelineIndex { + if (direction == EventTimeline.BACKWARDS) { + return this.start; + } else if (direction == EventTimeline.FORWARDS) { + return this.end; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + } + + /** + * Try to extend the window using events that are already in the underlying + * TimelineIndex. + * + * @param {string} direction EventTimeline.BACKWARDS to try extending it + * backwards; EventTimeline.FORWARDS to try extending it forwards. + * @param {number} size number of events to try to extend by. + * + * @return {boolean} true if the window was extended, false otherwise. + */ + public extend(direction: Direction, size: number): boolean { + const tl = this.getTimelineIndex(direction); + + if (!tl) { + debuglog("TimelineWindow: no timeline yet"); + return false; + } + + const count = (direction == EventTimeline.BACKWARDS) ? + tl.retreat(size) : tl.advance(size); + + if (count) { + this.eventCount += count; + debuglog("TimelineWindow: increased cap by " + count + + " (now " + this.eventCount + ")"); + // remove some events from the other end, if necessary + const excess = this.eventCount - this.windowLimit; + if (excess > 0) { + this.unpaginate(excess, direction != EventTimeline.BACKWARDS); + } + return true; + } + + return false; + } + + /** + * Check if this window can be extended + * + *

This returns true if we either have more events, or if we have a + * pagination token which means we can paginate in that direction. It does not + * necessarily mean that there are more events available in that direction at + * this time. + * + * @param {string} direction EventTimeline.BACKWARDS to check if we can + * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards + * + * @return {boolean} true if we can paginate in the given direction + */ + public canPaginate(direction: Direction): boolean { + const tl = this.getTimelineIndex(direction); + + if (!tl) { + debuglog("TimelineWindow: no timeline yet"); + return false; + } + + if (direction == EventTimeline.BACKWARDS) { + if (tl.index > tl.minIndex()) { + return true; + } + } else { + if (tl.index < tl.maxIndex()) { + return true; + } + } + + return Boolean(tl.timeline.getNeighbouringTimeline(direction) || + tl.timeline.getPaginationToken(direction)); + } + + /** + * Attempt to extend the window + * + * @param {string} direction EventTimeline.BACKWARDS to extend the window + * backwards (towards older events); EventTimeline.FORWARDS to go forwards. + * + * @param {number} size number of events to try to extend by. If fewer than this + * number are immediately available, then we return immediately rather than + * making an API call. + * + * @param {boolean} [makeRequest = true] whether we should make API calls to + * fetch further events if we don't have any at all. (This has no effect if + * the room already knows about additional events in the relevant direction, + * even if there are fewer than 'size' of them, as we will just return those + * we already know about.) + * + * @param {number} [requestLimit = 5] limit for the number of API requests we + * should make. + * + * @return {Promise} Resolves to a boolean which is true if more events + * were successfully retrieved. + */ + public paginate(direction: Direction, size: number, makeRequest: boolean, requestLimit: number): Promise { + // Either wind back the message cap (if there are enough events in the + // timeline to do so), or fire off a pagination request. + + if (makeRequest === undefined) { + makeRequest = true; + } + + if (requestLimit === undefined) { + requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT; + } + + const tl = this.getTimelineIndex(direction); + + if (!tl) { + debuglog("TimelineWindow: no timeline yet"); + return Promise.resolve(false); + } + + if (tl.pendingPaginate) { + return tl.pendingPaginate; + } + + // try moving the cap + if (this.extend(direction, size)) { + return Promise.resolve(true); + } + + if (!makeRequest || requestLimit === 0) { + // todo: should we return something different to indicate that there + // might be more events out there, but we haven't found them yet? + return Promise.resolve(false); + } + + // try making a pagination request + const token = tl.timeline.getPaginationToken(direction); + if (!token) { + debuglog("TimelineWindow: no token"); + return Promise.resolve(false); + } + + debuglog("TimelineWindow: starting request"); + + const prom = this.client.paginateEventTimeline(tl.timeline, { + backwards: direction == EventTimeline.BACKWARDS, + limit: size, + }).finally(function() { + tl.pendingPaginate = null; + }).then((r) => { + debuglog("TimelineWindow: request completed with result " + r); + if (!r) { + // end of timeline + return false; + } + + // recurse to advance the index into the results. + // + // If we don't get any new events, we want to make sure we keep asking + // the server for events for as long as we have a valid pagination + // token. In particular, we want to know if we've actually hit the + // start of the timeline, or if we just happened to know about all of + // the events thanks to https://matrix.org/jira/browse/SYN-645. + // + // On the other hand, we necessarily want to wait forever for the + // server to make its mind up about whether there are other events, + // because it gives a bad user experience + // (https://github.com/vector-im/vector-web/issues/1204). + return this.paginate(direction, size, true, requestLimit - 1); + }); + tl.pendingPaginate = prom; + return prom; + } + + /** + * Remove `delta` events from the start or end of the timeline. + * + * @param {number} delta number of events to remove from the timeline + * @param {boolean} startOfTimeline if events should be removed from the start + * of the timeline. + */ + public unpaginate(delta: number, startOfTimeline: boolean): void { + const tl = startOfTimeline ? this.start : this.end; + + // sanity-check the delta + if (delta > this.eventCount || delta < 0) { + throw new Error("Attemting to unpaginate " + delta + " events, but " + + "only have " + this.eventCount + " in the timeline"); + } + + while (delta > 0) { + const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta); + if (count <= 0) { + // sadness. This shouldn't be possible. + throw new Error( + "Unable to unpaginate any further, but still have " + + this.eventCount + " events"); + } + + delta -= count; + this.eventCount -= count; + debuglog("TimelineWindow.unpaginate: dropped " + count + + " (now " + this.eventCount + ")"); + } + } + + /** + * Get a list of the events currently in the window + * + * @return {MatrixEvent[]} the events in the window + */ + public getEvents(): MatrixEvent[] { + if (!this.start) { + // not yet loaded + return []; + } + + const result = []; + + // iterate through each timeline between this.start and this.end + // (inclusive). + let timeline = this.start.timeline; + // eslint-disable-next-line no-constant-condition + while (true) { + const events = timeline.getEvents(); + + // For the first timeline in the chain, we want to start at + // this.start.index. For the last timeline in the chain, we want to + // stop before this.end.index. Otherwise, we want to copy all of the + // events in the timeline. + // + // (Note that both this.start.index and this.end.index are relative + // to their respective timelines' BaseIndex). + // + let startIndex = 0; + let endIndex = events.length; + if (timeline === this.start.timeline) { + startIndex = this.start.index + timeline.getBaseIndex(); + } + if (timeline === this.end.timeline) { + endIndex = this.end.index + timeline.getBaseIndex(); + } + + for (let i = startIndex; i < endIndex; i++) { + result.push(events[i]); + } + + // if we're not done, iterate to the next timeline. + if (timeline === this.end.timeline) { + break; + } else { + timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS); + } + } + + return result; + } +} + +/** + * a thing which contains a timeline reference, and an index into it. + * + * @constructor + * @param {EventTimeline} timeline + * @param {number} index + * @private + */ +export class TimelineIndex { + public pendingPaginate?: Promise; + + // index: the indexes are relative to BaseIndex, so could well be negative. + constructor(public timeline: EventTimeline, public index: number) {} + + /** + * @return {number} the minimum possible value for the index in the current + * timeline + */ + public minIndex(): number { + return this.timeline.getBaseIndex() * -1; + } + + /** + * @return {number} the maximum possible value for the index in the current + * timeline (exclusive - ie, it actually returns one more than the index + * of the last element). + */ + public maxIndex(): number { + return this.timeline.getEvents().length - this.timeline.getBaseIndex(); + } + + /** + * Try move the index forward, or into the neighbouring timeline + * + * @param {number} delta number of events to advance by + * @return {number} number of events successfully advanced by + */ + public advance(delta: number): number { + if (!delta) { + return 0; + } + + // first try moving the index in the current timeline. See if there is room + // to do so. + let cappedDelta; + if (delta < 0) { + // we want to wind the index backwards. + // + // (this.minIndex() - this.index) is a negative number whose magnitude + // is the amount of room we have to wind back the index in the current + // timeline. We cap delta to this quantity. + cappedDelta = Math.max(delta, this.minIndex() - this.index); + if (cappedDelta < 0) { + this.index += cappedDelta; + return cappedDelta; + } + } else { + // we want to wind the index forwards. + // + // (this.maxIndex() - this.index) is a (positive) number whose magnitude + // is the amount of room we have to wind forward the index in the current + // timeline. We cap delta to this quantity. + cappedDelta = Math.min(delta, this.maxIndex() - this.index); + if (cappedDelta > 0) { + this.index += cappedDelta; + return cappedDelta; + } + } + + // the index is already at the start/end of the current timeline. + // + // next see if there is a neighbouring timeline to switch to. + const neighbour = this.timeline.getNeighbouringTimeline( + delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS); + if (neighbour) { + this.timeline = neighbour; + if (delta < 0) { + this.index = this.maxIndex(); + } else { + this.index = this.minIndex(); + } + + debuglog("paginate: switched to new neighbour"); + + // recurse, using the next timeline + return this.advance(delta); + } + + return 0; + } + + /** + * Try move the index backwards, or into the neighbouring timeline + * + * @param {number} delta number of events to retreat by + * @return {number} number of events successfully retreated by + */ + public retreat(delta: number): number { + return this.advance(delta * -1) * -1; + } +} From 399237e781848558169b3635ddd9c800bc0d409f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jul 2021 10:01:49 +0100 Subject: [PATCH 112/124] use better types --- src/timeline-window.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/timeline-window.ts b/src/timeline-window.ts index 5c16b7213..a03881f5e 100644 --- a/src/timeline-window.ts +++ b/src/timeline-window.ts @@ -265,7 +265,7 @@ export class TimelineWindow { * @return {Promise} Resolves to a boolean which is true if more events * were successfully retrieved. */ - public paginate(direction: Direction, size: number, makeRequest: boolean, requestLimit: number): Promise { + public paginate(direction: Direction, size: number, makeRequest = false, requestLimit = 5): Promise { // Either wind back the message cap (if there are enough events in the // timeline to do so), or fire off a pagination request. From a99c1e96d69277226c197865d71d2f5d4ff2d03d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jul 2021 21:05:58 +0100 Subject: [PATCH 113/124] fix field accesses in tests and default params --- spec/unit/event-timeline.spec.js | 8 ++++---- spec/unit/room.spec.js | 4 ++-- src/timeline-window.ts | 16 ++++++---------- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index ed3bfb4d5..f537f39eb 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -3,8 +3,8 @@ import { EventTimeline } from "../../src/models/event-timeline"; import { RoomState } from "../../src/models/room-state"; function mockRoomStates(timeline) { - timeline._startState = utils.mock(RoomState, "startState"); - timeline._endState = utils.mock(RoomState, "endState"); + timeline.startState = utils.mock(RoomState, "startState"); + timeline.endState = utils.mock(RoomState, "endState"); } describe("EventTimeline", function() { @@ -48,10 +48,10 @@ describe("EventTimeline", function() { }), ]; timeline.initialiseState(events); - expect(timeline._startState.setStateEvents).toHaveBeenCalledWith( + expect(timeline.startState.setStateEvents).toHaveBeenCalledWith( events, ); - expect(timeline._endState.setStateEvents).toHaveBeenCalledWith( + expect(timeline.endState.setStateEvents).toHaveBeenCalledWith( events, ); }); diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 34ec05f24..7675609d3 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -16,9 +16,9 @@ describe("Room", function() { beforeEach(function() { room = new Room(roomId); // mock RoomStates - room.oldState = room.getLiveTimeline()._startState = + room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState"); - room.currentState = room.getLiveTimeline()._endState = + room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); }); diff --git a/src/timeline-window.ts b/src/timeline-window.ts index a03881f5e..21912585d 100644 --- a/src/timeline-window.ts +++ b/src/timeline-window.ts @@ -265,18 +265,14 @@ export class TimelineWindow { * @return {Promise} Resolves to a boolean which is true if more events * were successfully retrieved. */ - public paginate(direction: Direction, size: number, makeRequest = false, requestLimit = 5): Promise { + public paginate( + direction: Direction, + size: number, + makeRequest = true, + requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT, + ): Promise { // Either wind back the message cap (if there are enough events in the // timeline to do so), or fire off a pagination request. - - if (makeRequest === undefined) { - makeRequest = true; - } - - if (requestLimit === undefined) { - requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT; - } - const tl = this.getTimelineIndex(direction); if (!tl) { From cab334ed7310d85eb6f7178f3d7fb0de44a092b5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jul 2021 22:47:50 +0100 Subject: [PATCH 114/124] Convert Sync and SyncAccumulator to Typescript --- src/client.ts | 9 +- src/crypto/index.ts | 13 +- src/models/event.ts | 2 +- src/models/room-member.ts | 1 + src/store/index.ts | 10 +- src/store/indexeddb.ts | 3 +- src/store/memory.ts | 4 +- src/store/stub.ts | 4 +- ...ync-accumulator.js => sync-accumulator.ts} | 294 ++- src/sync.js | 1710 ---------------- src/sync.ts | 1745 +++++++++++++++++ src/utils.ts | 6 +- 12 files changed, 1981 insertions(+), 1820 deletions(-) rename src/{sync-accumulator.js => sync-accumulator.ts} (76%) delete mode 100644 src/sync.js create mode 100644 src/sync.ts diff --git a/src/client.ts b/src/client.ts index c967a17b7..820f08e02 100644 --- a/src/client.ts +++ b/src/client.ts @@ -298,6 +298,11 @@ export interface IMatrixClientCreateOpts extends ICreateClientOpts { usingExternalCrypto?: boolean; } +export enum PendingEventOrdering { + Chronological = "chronological", + Detached = "detached", +} + export interface IStartClientOpts { /** * The event limit= to apply to initial sync. Default: 8. @@ -320,7 +325,7 @@ export interface IStartClientOpts { * pending messages will appear in a separate list, accessbile via {@link module:models/room#getPendingEvents}. * Default: "chronological". */ - pendingEventOrdering?: "chronological" | "detached"; + pendingEventOrdering?: PendingEventOrdering; /** * The number of milliseconds to wait on /sync. Default: 30000 (30 seconds). @@ -454,7 +459,7 @@ export class MatrixClient extends EventEmitter { protected fallbackICEServerAllowed = false; protected roomList: RoomList; protected syncApi: SyncApi; - protected pushRules: any; // TODO: Types + public pushRules: any; // TODO: Types protected syncLeftRoomsPromise: Promise; protected syncedLeftRooms = false; protected clientOpts: IStoredClientOpts; diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 070f8f5d9..10d471deb 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -57,6 +57,7 @@ import type { EncryptionAlgorithm, DecryptionAlgorithm } from "./algorithms/base import type { RoomList } from "./RoomList"; import { IRecoveryKey, IEncryptedEventInfo } from "./api"; import { IKeyBackupInfo } from "./keybackup"; +import { ISyncStateData } from "../sync"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -148,12 +149,6 @@ interface IUserOlmSession { }[]; } -interface ISyncData { - oldSyncToken?: string; - nextSyncToken: string; - catchingUp?: boolean; -} - interface ISyncDeviceLists { changed: string[]; left: string[]; @@ -2780,7 +2775,7 @@ export class Crypto extends EventEmitter { * @param {Object} syncDeviceLists device_lists field from /sync, or response from * /keys/changes */ - public async handleDeviceListChanges(syncData: ISyncData, syncDeviceLists: ISyncDeviceLists): Promise { + public async handleDeviceListChanges(syncData: ISyncStateData, syncDeviceLists: ISyncDeviceLists): Promise { // Initial syncs don't have device change lists. We'll either get the complete list // of changes for the interval or will have invalidated everything in willProcessSync if (!syncData.oldSyncToken) return; @@ -2870,7 +2865,7 @@ export class Crypto extends EventEmitter { * * @param {Object} syncData the data from the 'MatrixClient.sync' event */ - public async onSyncWillProcess(syncData: ISyncData): Promise { + public async onSyncWillProcess(syncData: ISyncStateData): Promise { if (!syncData.oldSyncToken) { // If there is no old sync token, we start all our tracking from // scratch, so mark everything as untracked. onCryptoEvent will @@ -2894,7 +2889,7 @@ export class Crypto extends EventEmitter { * * @param {Object} syncData the data from the 'MatrixClient.sync' event */ - public async onSyncCompleted(syncData: ISyncData): Promise { + public async onSyncCompleted(syncData: ISyncStateData): Promise { this.deviceList.setSyncToken(syncData.nextSyncToken); this.deviceList.saveIfDirty(); diff --git a/src/models/event.ts b/src/models/event.ts index 73648dca9..6b29bb6b0 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -74,7 +74,7 @@ export interface IContent { type StrippedState = Required>; -interface IUnsigned { +export interface IUnsigned { age?: number; prev_sender?: string; prev_content?: IContent; diff --git a/src/models/room-member.ts b/src/models/room-member.ts index 589fddd95..e7a98257b 100644 --- a/src/models/room-member.ts +++ b/src/models/room-member.ts @@ -29,6 +29,7 @@ import { RoomState } from "./room-state"; export class RoomMember extends EventEmitter { private _isOutOfBand = false; private _modified: number; + public _requestedProfileInfo: boolean; // used by sync.ts // XXX these should be read-only public typing = false; diff --git a/src/store/index.ts b/src/store/index.ts index a49f0ad23..80cdc8a1a 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -21,6 +21,14 @@ import { User } from "../models/user"; import { MatrixEvent } from "../models/event"; import { Filter } from "../filter"; import { RoomSummary } from "../models/room-summary"; +import { IMinimalEvent, IGroups, IRooms } from "../sync-accumulator"; + +export interface ISavedSync { + nextBatch: string; + roomsData: IRooms; + groupsData: IGroups; + accountData: IMinimalEvent[]; +} /** * Construct a stub store. This does no-ops on most store methods. @@ -199,7 +207,7 @@ export interface IStore { * client state to where it was at the last save, or null if there * is no saved sync data. */ - getSavedSync(): Promise; + getSavedSync(): Promise; /** * @return {Promise} If there is a saved sync, the nextBatch token diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts index 2e83d2ff1..f7f9fb8b9 100644 --- a/src/store/indexeddb.ts +++ b/src/store/indexeddb.ts @@ -24,6 +24,7 @@ import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend.js"; import { User } from "../models/user"; import { MatrixEvent } from "../models/event"; import { logger } from '../logger'; +import { ISavedSync } from "./index"; /** * This is an internal module. See {@link IndexedDBStore} for the public class. @@ -157,7 +158,7 @@ export class IndexedDBStore extends MemoryStore { * client state to where it was at the last save, or null if there * is no saved sync data. */ - public getSavedSync = this.degradable((): Promise => { + public getSavedSync = this.degradable((): Promise => { return this.backend.getSavedSync(); }, "getSavedSync"); diff --git a/src/store/memory.ts b/src/store/memory.ts index f682e10c1..452eb0c9a 100644 --- a/src/store/memory.ts +++ b/src/store/memory.ts @@ -27,7 +27,7 @@ import { MatrixEvent } from "../models/event"; import { RoomState } from "../models/room-state"; import { RoomMember } from "../models/room-member"; import { Filter } from "../filter"; -import { IStore } from "./index"; +import { ISavedSync, IStore } from "./index"; import { RoomSummary } from "../models/room-summary"; function isValidFilterId(filterId: string): boolean { @@ -373,7 +373,7 @@ export class MemoryStore implements IStore { * client state to where it was at the last save, or null if there * is no saved sync data. */ - public getSavedSync(): Promise { + public getSavedSync(): Promise { return Promise.resolve(null); } diff --git a/src/store/stub.ts b/src/store/stub.ts index c8dd293da..1a136d1d1 100644 --- a/src/store/stub.ts +++ b/src/store/stub.ts @@ -25,7 +25,7 @@ import { Room } from "../models/room"; import { User } from "../models/user"; import { MatrixEvent } from "../models/event"; import { Filter } from "../filter"; -import { IStore } from "./index"; +import { ISavedSync, IStore } from "./index"; import { RoomSummary } from "../models/room-summary"; /** @@ -243,7 +243,7 @@ export class StubStore implements IStore { * client state to where it was at the last save, or null if there * is no saved sync data. */ - public getSavedSync(): Promise { + public getSavedSync(): Promise { return Promise.resolve(null); } diff --git a/src/sync-accumulator.js b/src/sync-accumulator.ts similarity index 76% rename from src/sync-accumulator.js rename to src/sync-accumulator.ts index 1f190538c..8e2c14485 100644 --- a/src/sync-accumulator.js +++ b/src/sync-accumulator.ts @@ -1,7 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2017 - 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. @@ -23,6 +21,153 @@ limitations under the License. import { logger } from './logger'; import { deepCopy } from "./utils"; +import { IContent, IUnsigned } from "./models/event"; +import { IRoomSummary } from "./models/room-summary"; +import { EventType } from "./@types/event"; + +interface IOpts { + maxTimelineEntries?: number; +} + +export interface IMinimalEvent { + content: IContent; + type: EventType | string; +} + +export interface IEphemeral { + events: IMinimalEvent[]; +} + +/* eslint-disable camelcase */ +interface IUnreadNotificationCounts { + highlight_count: number; + notification_count: number; +} + +export interface IRoomEvent extends IMinimalEvent { + event_id: string; + sender: string; + origin_server_ts: number; + unsigned?: IUnsigned; + /** @deprecated - legacy field */ + age?: number; +} + +export interface IStateEvent extends IRoomEvent { + prev_content?: IContent; + state_key: string; +} + +interface IState { + events: IStateEvent[]; +} + +export interface ITimeline { + events: Array; + limited: boolean; + prev_batch: string; +} + +export interface IJoinedRoom { + summary: IRoomSummary; + state: IState; + timeline: ITimeline; + ephemeral: IEphemeral; + account_data: IAccountData; + unread_notifications: IUnreadNotificationCounts; +} + +export interface IStrippedState { + content: IContent; + state_key: string; + type: EventType | string; + sender: string; +} + +export interface IInviteState { + events: IStrippedState[]; +} + +export interface IInvitedRoom { + invite_state: IInviteState; +} + +export interface ILeftRoom { + state: IState; + timeline: ITimeline; + account_data: IAccountData; +} + +export interface IRooms { + [Category.Join]: Record; + [Category.Invite]: Record; + [Category.Leave]: Record; +} + +interface IPresence { + events: IMinimalEvent[]; +} + +interface IAccountData { + events: IMinimalEvent[]; +} + +interface IToDeviceEvent { + content: IContent; + sender: string; + type: string; +} + +interface IToDevice { + events: IToDeviceEvent[]; +} + +interface IDeviceLists { + changed: string[]; + left: string[]; +} + +export interface IGroups { + [Category.Join]: object; + [Category.Invite]: object; + [Category.Leave]: object; +} + +export interface ISyncResponse { + next_batch: string; + rooms: IRooms; + presence?: IPresence; + account_data: IAccountData; + to_device?: IToDevice; + device_lists?: IDeviceLists; + device_one_time_keys_count?: Record; + + groups: IGroups; // unspecced +} +/* eslint-enable camelcase */ + +export enum Category { + Invite = "invite", + Leave = "leave", + Join = "join", +} + +interface IRoom { + _currentState: { [eventType: string]: { [stateKey: string]: IStateEvent } }; + _timeline: { + event: IRoomEvent | IStateEvent; + token: string | null; + }[]; + _summary: Partial; + _accountData: { [eventType: string]: IMinimalEvent }; + _unreadNotifications: Partial; + _readReceipts: { + [userId: string]: { + data: IMinimalEvent; + eventId: string; + }; + }; +} /** * The purpose of this class is to accumulate /sync responses such that a @@ -35,6 +180,22 @@ import { deepCopy } from "./utils"; * rather than asking the server to do an initial sync on startup. */ export class SyncAccumulator { + private accountData: Record = {}; // $event_type: Object + private inviteRooms: Record = {}; // $roomId: { ... sync 'invite' json data ... } + private joinRooms: { [roomId: string]: IRoom } = {}; + // the /sync token which corresponds to the last time rooms were + // accumulated. We remember this so that any caller can obtain a + // coherent /sync response and know at what point they should be + // streaming from without losing events. + private nextBatch: string = null; + + // { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } } + private groups: Record = { + invite: {}, + join: {}, + leave: {}, + }; + /** * @param {Object} opts * @param {Number=} opts.maxTimelineEntries The ideal maximum number of @@ -44,57 +205,18 @@ export class SyncAccumulator { * never be more. This cannot be 0 or else it makes it impossible to scroll * back in a room. Default: 50. */ - constructor(opts) { - opts = opts || {}; - opts.maxTimelineEntries = opts.maxTimelineEntries || 50; - this.opts = opts; - this.accountData = { - //$event_type: Object - }; - this.inviteRooms = { - //$roomId: { ... sync 'invite' json data ... } - }; - this.joinRooms = { - //$roomId: { - // _currentState: { $event_type: { $state_key: json } }, - // _timeline: [ - // { event: $event, token: null|token }, - // { event: $event, token: null|token }, - // { event: $event, token: null|token }, - // ... - // ], - // _summary: { - // m.heroes: [ $user_id ], - // m.joined_member_count: $count, - // m.invited_member_count: $count - // }, - // _accountData: { $event_type: json }, - // _unreadNotifications: { ... unread_notifications JSON ... }, - // _readReceipts: { $user_id: { data: $json, eventId: $event_id }} - //} - }; - // the /sync token which corresponds to the last time rooms were - // accumulated. We remember this so that any caller can obtain a - // coherent /sync response and know at what point they should be - // streaming from without losing events. - this.nextBatch = null; - - // { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } } - this.groups = { - invite: {}, - join: {}, - leave: {}, - }; + constructor(private readonly opts: IOpts = {}) { + this.opts.maxTimelineEntries = this.opts.maxTimelineEntries || 50; } - accumulate(syncResponse, fromDatabase) { - this._accumulateRooms(syncResponse, fromDatabase); - this._accumulateGroups(syncResponse); - this._accumulateAccountData(syncResponse); + public accumulate(syncResponse: ISyncResponse, fromDatabase = false): void { + this.accumulateRooms(syncResponse, fromDatabase); + this.accumulateGroups(syncResponse); + this.accumulateAccountData(syncResponse); this.nextBatch = syncResponse.next_batch; } - _accumulateAccountData(syncResponse) { + private accumulateAccountData(syncResponse: ISyncResponse): void { if (!syncResponse.account_data || !syncResponse.account_data.events) { return; } @@ -109,34 +231,31 @@ export class SyncAccumulator { * @param {Object} syncResponse the complete /sync JSON * @param {boolean} fromDatabase True if the sync response is one saved to the database */ - _accumulateRooms(syncResponse, fromDatabase) { + private accumulateRooms(syncResponse: ISyncResponse, fromDatabase = false): void { if (!syncResponse.rooms) { return; } if (syncResponse.rooms.invite) { Object.keys(syncResponse.rooms.invite).forEach((roomId) => { - this._accumulateRoom( - roomId, "invite", syncResponse.rooms.invite[roomId], fromDatabase, - ); + this.accumulateRoom(roomId, Category.Invite, syncResponse.rooms.invite[roomId], fromDatabase); }); } if (syncResponse.rooms.join) { Object.keys(syncResponse.rooms.join).forEach((roomId) => { - this._accumulateRoom( - roomId, "join", syncResponse.rooms.join[roomId], fromDatabase, - ); + this.accumulateRoom(roomId, Category.Join, syncResponse.rooms.join[roomId], fromDatabase); }); } if (syncResponse.rooms.leave) { Object.keys(syncResponse.rooms.leave).forEach((roomId) => { - this._accumulateRoom( - roomId, "leave", syncResponse.rooms.leave[roomId], fromDatabase, - ); + this.accumulateRoom(roomId, Category.Leave, syncResponse.rooms.leave[roomId], fromDatabase); }); } } - _accumulateRoom(roomId, category, data, fromDatabase) { + private accumulateRoom(roomId: string, category: Category.Invite, data: IInvitedRoom, fromDatabase: boolean): void; + private accumulateRoom(roomId: string, category: Category.Join, data: IJoinedRoom, fromDatabase: boolean): void; + private accumulateRoom(roomId: string, category: Category.Leave, data: ILeftRoom, fromDatabase: boolean): void; + private accumulateRoom(roomId: string, category: Category, data: any, fromDatabase = false): void { // Valid /sync state transitions // +--------+ <======+ 1: Accept an invite // +== | INVITE | | (5) 2: Leave a room @@ -149,10 +268,11 @@ export class SyncAccumulator { // // * equivalent to "no state" switch (category) { - case "invite": // (5) - this._accumulateInviteState(roomId, data); + case Category.Invite: // (5) + this.accumulateInviteState(roomId, data as IInvitedRoom); break; - case "join": + + case Category.Join: if (this.inviteRooms[roomId]) { // (1) // was previously invite, now join. We expect /sync to give // the entire state and timeline on 'join', so delete previous @@ -160,21 +280,23 @@ export class SyncAccumulator { delete this.inviteRooms[roomId]; } // (3) - this._accumulateJoinState(roomId, data, fromDatabase); + this.accumulateJoinState(roomId, data as IJoinedRoom, fromDatabase); break; - case "leave": + + case Category.Leave: if (this.inviteRooms[roomId]) { // (4) delete this.inviteRooms[roomId]; } else { // (2) delete this.joinRooms[roomId]; } break; + default: logger.error("Unknown cateogory: ", category); } } - _accumulateInviteState(roomId, data) { + private accumulateInviteState(roomId: string, data: IInvitedRoom): void { if (!data.invite_state || !data.invite_state.events) { // no new data return; } @@ -204,7 +326,7 @@ export class SyncAccumulator { } // Accumulate timeline and state events in a room. - _accumulateJoinState(roomId, data, fromDatabase) { + private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void { // We expect this function to be called a lot (every /sync) so we want // this to be fast. /sync stores events in an array but we often want // to clobber based on type/state_key. Rather than convert arrays to @@ -338,7 +460,7 @@ export class SyncAccumulator { setState(currentData._currentState, e); // append the event to the timeline. The back-pagination token // corresponds to the first event in the timeline - let transformedEvent; + let transformedEvent: IRoomEvent & { _localTs?: number }; if (!fromDatabase) { transformedEvent = Object.assign({}, e); if (transformedEvent.unsigned !== undefined) { @@ -379,35 +501,29 @@ export class SyncAccumulator { * Accumulate incremental /sync group data. * @param {Object} syncResponse the complete /sync JSON */ - _accumulateGroups(syncResponse) { + private accumulateGroups(syncResponse: ISyncResponse): void { if (!syncResponse.groups) { return; } if (syncResponse.groups.invite) { Object.keys(syncResponse.groups.invite).forEach((groupId) => { - this._accumulateGroup( - groupId, "invite", syncResponse.groups.invite[groupId], - ); + this.accumulateGroup(groupId, Category.Invite, syncResponse.groups.invite[groupId]); }); } if (syncResponse.groups.join) { Object.keys(syncResponse.groups.join).forEach((groupId) => { - this._accumulateGroup( - groupId, "join", syncResponse.groups.join[groupId], - ); + this.accumulateGroup(groupId, Category.Join, syncResponse.groups.join[groupId]); }); } if (syncResponse.groups.leave) { Object.keys(syncResponse.groups.leave).forEach((groupId) => { - this._accumulateGroup( - groupId, "leave", syncResponse.groups.leave[groupId], - ); + this.accumulateGroup(groupId, Category.Leave, syncResponse.groups.leave[groupId]); }); } } - _accumulateGroup(groupId, category, data) { - for (const cat of ['invite', 'join', 'leave']) { + private accumulateGroup(groupId: string, category: Category, data: object): void { + for (const cat of [Category.Invite, Category.Leave, Category.Join]) { delete this.groups[cat][groupId]; } this.groups[category][groupId] = data; @@ -428,7 +544,7 @@ export class SyncAccumulator { * /sync response from the 'rooms' key onwards. The "accountData" key is * a list of raw events which represent global account data. */ - getJSON(forDatabase) { + public getJSON(forDatabase = false): object { const data = { join: {}, invite: {}, @@ -501,14 +617,14 @@ export class SyncAccumulator { roomJson.timeline.prev_batch = msgData.token; } - let transformedEvent; - if (!forDatabase && msgData.event._localTs) { + let transformedEvent: (IRoomEvent | IStateEvent) & { _localTs?: number }; + if (!forDatabase && msgData.event["_localTs"]) { // This means we have to copy each event so we can fix it up to // set a correct 'age' parameter whilst keeping the local timestamp // on our stored event. If this turns out to be a bottleneck, it could // be optimised either by doing this in the main process after the data // has been structured-cloned to go between the worker & main process, - // or special-casing data from saved syncs to read the local timstamp + // or special-casing data from saved syncs to read the local timestamp // directly rather than turning it into age to then immediately be // transformed back again into a local timestamp. transformedEvent = Object.assign({}, msgData.event); @@ -517,7 +633,7 @@ export class SyncAccumulator { } delete transformedEvent._localTs; transformedEvent.unsigned = transformedEvent.unsigned || {}; - transformedEvent.unsigned.age = Date.now() - msgData.event._localTs; + transformedEvent.unsigned.age = Date.now() - msgData.event["_localTs"]; } else { transformedEvent = msgData.event; } @@ -575,17 +691,17 @@ export class SyncAccumulator { }; } - getNextBatchToken() { + public getNextBatchToken(): string { return this.nextBatch; } } -function setState(eventMap, event) { - if (event.state_key === null || event.state_key === undefined || !event.type) { +function setState(eventMap: Record>, event: IRoomEvent | IStateEvent): void { + if ((event as IStateEvent).state_key === null || (event as IStateEvent).state_key === undefined || !event.type) { return; } if (!eventMap[event.type]) { eventMap[event.type] = Object.create(null); } - eventMap[event.type][event.state_key] = event; + eventMap[event.type][(event as IStateEvent).state_key] = event as IStateEvent; } diff --git a/src/sync.js b/src/sync.js deleted file mode 100644 index 9d114943f..000000000 --- a/src/sync.js +++ /dev/null @@ -1,1710 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 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: - * 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 - * an alternative syncing API, we may want to have a proper syncing interface - * for HTTP and WS at some point. - */ - -import { User } from "./models/user"; -import { Room } from "./models/room"; -import { Group } from "./models/group"; -import * as utils from "./utils"; -import { Filter } from "./filter"; -import { EventTimeline } from "./models/event-timeline"; -import { PushProcessor } from "./pushprocessor"; -import { logger } from './logger'; -import { InvalidStoreError } from './errors'; - -const DEBUG = true; - -// /sync requests allow you to set a timeout= but the request may continue -// beyond that and wedge forever, so we need to track how long we are willing -// to keep open the connection. This constant is *ADDED* to the timeout= value -// to determine the max time we're willing to wait. -const BUFFER_PERIOD_MS = 80 * 1000; - -// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed -// to RECONNECTING. This is needed to inform the client of server issues when the -// keepAlive is successful but the server /sync fails. -const FAILED_SYNC_ERROR_THRESHOLD = 3; - -function getFilterName(userId, suffix) { - // scope this on the user ID because people may login on many accounts - // and they all need to be stored! - return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : ""); -} - -function debuglog(...params) { - if (!DEBUG) { - return; - } - logger.log(...params); -} - -/** - * Internal class - unstable. - * Construct an entity which is able to sync with a homeserver. - * @constructor - * @param {MatrixClient} client The matrix client instance to use. - * @param {Object} opts Config options - * @param {module:crypto=} opts.crypto Crypto manager - * @param {Function=} opts.canResetEntireTimeline A function which is called - * with a room ID and returns a boolean. It should return 'true' if the SDK can - * SAFELY remove events from this room. It may not be safe to remove events if - * there are other references to the timelines for this room. - * Default: returns false. - * @param {Boolean=} opts.disablePresence True to perform syncing without automatically - * updating presence. - */ -export function SyncApi(client, opts) { - this.client = client; - opts = opts || {}; - opts.initialSyncLimit = ( - opts.initialSyncLimit === undefined ? 8 : opts.initialSyncLimit - ); - opts.resolveInvitesToProfiles = opts.resolveInvitesToProfiles || false; - opts.pollTimeout = opts.pollTimeout || (30 * 1000); - opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; - if (!opts.canResetEntireTimeline) { - opts.canResetEntireTimeline = function(roomId) { - return false; - }; - } - this.opts = opts; - this._peekRoom = null; - this._currentSyncRequest = null; - this._syncState = null; - this._syncStateData = null; // additional data (eg. error object for failed sync) - this._catchingUp = false; - this._running = false; - this._keepAliveTimer = null; - this._connectionReturnedDefer = null; - this._notifEvents = []; // accumulator of sync events in the current sync response - this._failedSyncCount = 0; // Number of consecutive failed /sync requests - this._storeIsInvalid = false; // flag set if the store needs to be cleared before we can start - - if (client.getNotifTimelineSet()) { - client.reEmitter.reEmit(client.getNotifTimelineSet(), - ["Room.timeline", "Room.timelineReset"]); - } -} - -/** - * @param {string} roomId - * @return {Room} - */ -SyncApi.prototype.createRoom = function(roomId) { - const client = this.client; - const { - timelineSupport, - unstableClientRelationAggregation, - } = client; - const room = new Room(roomId, client, client.getUserId(), { - lazyLoadMembers: this.opts.lazyLoadMembers, - pendingEventOrdering: this.opts.pendingEventOrdering, - timelineSupport, - unstableClientRelationAggregation, - }); - client.reEmitter.reEmit(room, ["Room.name", "Room.timeline", - "Room.redaction", - "Room.redactionCancelled", - "Room.receipt", "Room.tags", - "Room.timelineReset", - "Room.localEchoUpdated", - "Room.accountData", - "Room.myMembership", - "Room.replaceEvent", - ]); - this._registerStateListeners(room); - return room; -}; - -/** - * @param {string} groupId - * @return {Group} - */ -SyncApi.prototype.createGroup = function(groupId) { - const client = this.client; - const group = new Group(groupId); - client.reEmitter.reEmit(group, ["Group.profile", "Group.myMembership"]); - client.store.storeGroup(group); - return group; -}; - -/** - * @param {Room} room - * @private - */ -SyncApi.prototype._registerStateListeners = function(room) { - const client = this.client; - // we need to also re-emit room state and room member events, so hook it up - // to the client now. We need to add a listener for RoomState.members in - // order to hook them correctly. (TODO: find a better way?) - client.reEmitter.reEmit(room.currentState, [ - "RoomState.events", "RoomState.members", "RoomState.newMember", - ]); - room.currentState.on("RoomState.newMember", function(event, state, member) { - member.user = client.getUser(member.userId); - client.reEmitter.reEmit( - member, - [ - "RoomMember.name", "RoomMember.typing", "RoomMember.powerLevel", - "RoomMember.membership", - ], - ); - }); -}; - -/** - * @param {Room} room - * @private - */ -SyncApi.prototype._deregisterStateListeners = function(room) { - // could do with a better way of achieving this. - room.currentState.removeAllListeners("RoomState.events"); - room.currentState.removeAllListeners("RoomState.members"); - room.currentState.removeAllListeners("RoomState.newMember"); -}; - -/** - * Sync rooms the user has left. - * @return {Promise} Resolved when they've been added to the store. - */ -SyncApi.prototype.syncLeftRooms = function() { - const client = this.client; - const self = this; - - // grab a filter with limit=1 and include_leave=true - const filter = new Filter(this.client.credentials.userId); - filter.setTimelineLimit(1); - filter.setIncludeLeaveRooms(true); - - const localTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS; - const qps = { - timeout: 0, // don't want to block since this is a single isolated req - }; - - return client.getOrCreateFilter( - getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter, - ).then(function(filterId) { - qps.filter = filterId; - return client.http.authedRequest( - undefined, "GET", "/sync", qps, undefined, localTimeoutMs, - ); - }).then(function(data) { - let leaveRooms = []; - if (data.rooms && data.rooms.leave) { - leaveRooms = self._mapSyncResponseToRoomArray(data.rooms.leave); - } - const rooms = []; - leaveRooms.forEach(function(leaveObj) { - const room = leaveObj.room; - rooms.push(room); - if (!leaveObj.isBrandNewRoom) { - // the intention behind syncLeftRooms is to add in rooms which were - // *omitted* from the initial /sync. Rooms the user were joined to - // but then left whilst the app is running will appear in this list - // and we do not want to bother with them since they will have the - // current state already (and may get dupe messages if we add - // yet more timeline events!), so skip them. - // NB: When we persist rooms to localStorage this will be more - // complicated... - return; - } - leaveObj.timeline = leaveObj.timeline || {}; - const timelineEvents = - self._mapSyncEventsFormat(leaveObj.timeline, room); - const stateEvents = self._mapSyncEventsFormat(leaveObj.state, room); - - // set the back-pagination token. Do this *before* adding any - // events so that clients can start back-paginating. - room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, - EventTimeline.BACKWARDS); - - self._processRoomEvents(room, stateEvents, timelineEvents); - - room.recalculate(); - client.store.storeRoom(room); - client.emit("Room", room); - - self._processEventsForNotifs(room, timelineEvents); - }); - return rooms; - }); -}; - -/** - * Peek into a room. This will result in the room in question being synced so it - * is accessible via getRooms(). Live updates for the room will be provided. - * @param {string} roomId The room ID to peek into. - * @return {Promise} A promise which resolves once the room has been added to the - * store. - */ -SyncApi.prototype.peek = function(roomId) { - if (this._peekRoom && this._peekRoom.roomId === roomId) { - return Promise.resolve(this._peekRoom); - } - - const client = this.client; - this._peekRoom = this.createRoom(roomId); - return this.client.roomInitialSync(roomId, 20).then((response) => { - // make sure things are init'd - response.messages = response.messages || {}; - response.messages.chunk = response.messages.chunk || []; - response.state = response.state || []; - - // FIXME: Mostly duplicated from _processRoomEvents but not entirely - // because "state" in this API is at the BEGINNING of the chunk - const oldStateEvents = utils.deepCopy(response.state) - .map(client.getEventMapper()); - const stateEvents = response.state.map(client.getEventMapper()); - const messages = response.messages.chunk.map(client.getEventMapper()); - - // XXX: copypasted from /sync until we kill off this - // minging v1 API stuff) - // handle presence events (User objects) - if (response.presence && Array.isArray(response.presence)) { - response.presence.map(client.getEventMapper()).forEach( - function(presenceEvent) { - let user = client.store.getUser(presenceEvent.getContent().user_id); - if (user) { - user.setPresenceEvent(presenceEvent); - } else { - user = createNewUser(client, presenceEvent.getContent().user_id); - user.setPresenceEvent(presenceEvent); - client.store.storeUser(user); - } - client.emit("event", presenceEvent); - }); - } - - // set the pagination token before adding the events in case people - // fire off pagination requests in response to the Room.timeline - // events. - if (response.messages.start) { - this._peekRoom.oldState.paginationToken = response.messages.start; - } - - // set the state of the room to as it was after the timeline executes - this._peekRoom.oldState.setStateEvents(oldStateEvents); - this._peekRoom.currentState.setStateEvents(stateEvents); - - this._resolveInvites(this._peekRoom); - this._peekRoom.recalculate(); - - // roll backwards to diverge old state. addEventsToTimeline - // will overwrite the pagination token, so make sure it overwrites - // it with the right thing. - this._peekRoom.addEventsToTimeline(messages.reverse(), true, - this._peekRoom.getLiveTimeline(), - response.messages.start); - - client.store.storeRoom(this._peekRoom); - client.emit("Room", this._peekRoom); - - this._peekPoll(this._peekRoom); - return this._peekRoom; - }); -}; - -/** - * Stop polling for updates in the peeked room. NOPs if there is no room being - * peeked. - */ -SyncApi.prototype.stopPeeking = function() { - this._peekRoom = null; -}; - -/** - * Do a peek room poll. - * @param {Room} peekRoom - * @param {string?} token from= token - */ -SyncApi.prototype._peekPoll = function(peekRoom, token) { - if (this._peekRoom !== peekRoom) { - debuglog("Stopped peeking in room %s", peekRoom.roomId); - return; - } - - const self = this; - // FIXME: gut wrenching; hard-coded timeout values - this.client.http.authedRequest(undefined, "GET", "/events", { - room_id: peekRoom.roomId, - timeout: 30 * 1000, - from: token, - }, undefined, 50 * 1000).then(function(res) { - if (self._peekRoom !== peekRoom) { - debuglog("Stopped peeking in room %s", peekRoom.roomId); - return; - } - // We have a problem that we get presence both from /events and /sync - // however, /sync only returns presence for users in rooms - // you're actually joined to. - // in order to be sure to get presence for all of the users in the - // peeked room, we handle presence explicitly here. This may result - // in duplicate presence events firing for some users, which is a - // performance drain, but such is life. - // XXX: copypasted from /sync until we can kill this minging v1 stuff. - - res.chunk.filter(function(e) { - return e.type === "m.presence"; - }).map(self.client.getEventMapper()).forEach(function(presenceEvent) { - let user = self.client.store.getUser(presenceEvent.getContent().user_id); - if (user) { - user.setPresenceEvent(presenceEvent); - } else { - user = createNewUser(self.client, presenceEvent.getContent().user_id); - user.setPresenceEvent(presenceEvent); - self.client.store.storeUser(user); - } - self.client.emit("event", presenceEvent); - }); - - // strip out events which aren't for the given room_id (e.g presence) - // and also ephemeral events (which we're assuming is anything without - // and event ID because the /events API doesn't separate them). - const events = res.chunk.filter(function(e) { - return e.room_id === peekRoom.roomId && e.event_id; - }).map(self.client.getEventMapper()); - - peekRoom.addLiveEvents(events); - self._peekPoll(peekRoom, res.end); - }, function(err) { - logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err); - setTimeout(function() { - self._peekPoll(peekRoom, token); - }, 30 * 1000); - }); -}; - -/** - * Returns the current state of this sync object - * @see module:client~MatrixClient#event:"sync" - * @return {?String} - */ -SyncApi.prototype.getSyncState = function() { - return this._syncState; -}; - -/** - * Returns the additional data object associated with - * the current sync state, or null if there is no - * such data. - * Sync errors, if available, are put in the 'error' key of - * this object. - * @return {?Object} - */ -SyncApi.prototype.getSyncStateData = function() { - return this._syncStateData; -}; - -SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise, err) { - // Wait for the saved sync to complete - we send the pushrules and filter requests - // before the saved sync has finished so they can run in parallel, but only process - // the results after the saved sync is done. Equivalently, we wait for it to finish - // before reporting failures from these functions. - await savedSyncPromise; - const keepaliveProm = this._startKeepAlives(); - this._updateSyncState("ERROR", { error: err }); - await keepaliveProm; -}; - -/** - * Is the lazy loading option different than in previous session? - * @param {bool} lazyLoadMembers current options for lazy loading - * @return {bool} whether or not the option has changed compared to the previous session */ -SyncApi.prototype._wasLazyLoadingToggled = async function(lazyLoadMembers) { - lazyLoadMembers = !!lazyLoadMembers; - // assume it was turned off before - // if we don't know any better - let lazyLoadMembersBefore = false; - const isStoreNewlyCreated = await this.client.store.isNewlyCreated(); - if (!isStoreNewlyCreated) { - const prevClientOptions = await this.client.store.getClientOptions(); - if (prevClientOptions) { - lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers; - } - return lazyLoadMembersBefore !== lazyLoadMembers; - } - return false; -}; - -SyncApi.prototype._shouldAbortSync = function(error) { - if (error.errcode === "M_UNKNOWN_TOKEN") { - // The logout already happened, we just need to stop. - logger.warn("Token no longer valid - assuming logout"); - this.stop(); - return true; - } - return false; -}; - -/** - * Main entry point - */ -SyncApi.prototype.sync = function() { - const client = this.client; - const self = this; - - this._running = true; - - if (global.window && global.window.addEventListener) { - this._onOnlineBound = this._onOnline.bind(this); - global.window.addEventListener("online", this._onOnlineBound, false); - } - - let savedSyncPromise = Promise.resolve(); - let savedSyncToken = null; - - // We need to do one-off checks before we can begin the /sync loop. - // These are: - // 1) We need to get push rules so we can check if events should bing as we get - // them from /sync. - // 2) We need to get/create a filter which we can use for /sync. - // 3) We need to check the lazy loading option matches what was used in the - // stored sync. If it doesn't, we can't use the stored sync. - - async function getPushRules() { - try { - debuglog("Getting push rules..."); - const result = await client.getPushRules(); - debuglog("Got push rules"); - - client.pushRules = result; - } catch (err) { - logger.error("Getting push rules failed", err); - if (self._shouldAbortSync(err)) return; - // wait for saved sync to complete before doing anything else, - // otherwise the sync state will end up being incorrect - debuglog("Waiting for saved sync before retrying push rules..."); - await self.recoverFromSyncStartupError(savedSyncPromise, err); - getPushRules(); - return; - } - checkLazyLoadStatus(); // advance to the next stage - } - - function buildDefaultFilter() { - const filter = new Filter(client.credentials.userId); - filter.setTimelineLimit(self.opts.initialSyncLimit); - return filter; - } - - const checkLazyLoadStatus = async () => { - debuglog("Checking lazy load status..."); - if (this.opts.lazyLoadMembers && client.isGuest()) { - this.opts.lazyLoadMembers = false; - } - if (this.opts.lazyLoadMembers) { - debuglog("Checking server lazy load support..."); - const supported = await client.doesServerSupportLazyLoading(); - if (supported) { - debuglog("Enabling lazy load on sync filter..."); - if (!this.opts.filter) { - this.opts.filter = buildDefaultFilter(); - } - this.opts.filter.setLazyLoadMembers(true); - } else { - debuglog("LL: lazy loading requested but not supported " + - "by server, so disabling"); - this.opts.lazyLoadMembers = false; - } - } - // need to vape the store when enabling LL and wasn't enabled before - debuglog("Checking whether lazy loading has changed in store..."); - const shouldClear = await this._wasLazyLoadingToggled(this.opts.lazyLoadMembers); - if (shouldClear) { - this._storeIsInvalid = true; - const reason = InvalidStoreError.TOGGLED_LAZY_LOADING; - const error = new InvalidStoreError(reason, !!this.opts.lazyLoadMembers); - this._updateSyncState("ERROR", { error }); - // bail out of the sync loop now: the app needs to respond to this error. - // we leave the state as 'ERROR' which isn't great since this normally means - // we're retrying. The client must be stopped before clearing the stores anyway - // so the app should stop the client, clear the store and start it again. - logger.warn("InvalidStoreError: store is not usable: stopping sync."); - return; - } - if (this.opts.lazyLoadMembers && this.opts.crypto) { - this.opts.crypto.enableLazyLoading(); - } - try { - debuglog("Storing client options..."); - await this.client.storeClientOptions(); - debuglog("Stored client options"); - } catch (err) { - logger.error("Storing client options failed", err); - throw err; - } - - getFilter(); // Now get the filter and start syncing - }; - - async function getFilter() { - debuglog("Getting filter..."); - let filter; - if (self.opts.filter) { - filter = self.opts.filter; - } else { - filter = buildDefaultFilter(); - } - - let filterId; - try { - filterId = await client.getOrCreateFilter( - getFilterName(client.credentials.userId), filter, - ); - } catch (err) { - logger.error("Getting filter failed", err); - if (self._shouldAbortSync(err)) return; - // wait for saved sync to complete before doing anything else, - // otherwise the sync state will end up being incorrect - debuglog("Waiting for saved sync before retrying filter..."); - await self.recoverFromSyncStartupError(savedSyncPromise, err); - getFilter(); - return; - } - // reset the notifications timeline to prepare it to paginate from - // the current point in time. - // The right solution would be to tie /sync pagination tokens into - // /notifications API somehow. - client.resetNotifTimelineSet(); - - if (self._currentSyncRequest === null) { - // Send this first sync request here so we can then wait for the saved - // sync data to finish processing before we process the results of this one. - debuglog("Sending first sync request..."); - self._currentSyncRequest = self._doSyncRequest({ filterId }, savedSyncToken); - } - - // Now wait for the saved sync to finish... - debuglog("Waiting for saved sync before starting sync processing..."); - await savedSyncPromise; - self._sync({ filterId }); - } - - if (client.isGuest()) { - // no push rules for guests, no access to POST filter for guests. - self._sync({}); - } else { - // Pull the saved sync token out first, before the worker starts sending - // all the sync data which could take a while. This will let us send our - // first incremental sync request before we've processed our saved data. - debuglog("Getting saved sync token..."); - savedSyncPromise = client.store.getSavedSyncToken().then((tok) => { - debuglog("Got saved sync token"); - savedSyncToken = tok; - debuglog("Getting saved sync..."); - return client.store.getSavedSync(); - }).then((savedSync) => { - debuglog(`Got reply from saved sync, exists? ${!!savedSync}`); - if (savedSync) { - return self._syncFromCache(savedSync); - } - }).catch(err => { - logger.error("Getting saved sync failed", err); - }); - // Now start the first incremental sync request: this can also - // take a while so if we set it going now, we can wait for it - // to finish while we process our saved sync data. - getPushRules(); - } -}; - -/** - * Stops the sync object from syncing. - */ -SyncApi.prototype.stop = function() { - debuglog("SyncApi.stop"); - if (global.window) { - global.window.removeEventListener("online", this._onOnlineBound, false); - this._onOnlineBound = undefined; - } - this._running = false; - if (this._currentSyncRequest) { - this._currentSyncRequest.abort(); - } - if (this._keepAliveTimer) { - clearTimeout(this._keepAliveTimer); - this._keepAliveTimer = null; - } -}; - -/** - * Retry a backed off syncing request immediately. This should only be used when - * the user explicitly attempts to retry their lost connection. - * @return {boolean} True if this resulted in a request being retried. - */ -SyncApi.prototype.retryImmediately = function() { - if (!this._connectionReturnedDefer) { - return false; - } - this._startKeepAlives(0); - return true; -}; -/** - * Process a single set of cached sync data. - * @param {Object} savedSync a saved sync that was persisted by a store. This - * should have been acquired via client.store.getSavedSync(). - */ -SyncApi.prototype._syncFromCache = async function(savedSync) { - debuglog("sync(): not doing HTTP hit, instead returning stored /sync data"); - - const nextSyncToken = savedSync.nextBatch; - - // Set sync token for future incremental syncing - this.client.store.setSyncToken(nextSyncToken); - - // No previous sync, set old token to null - const syncEventData = { - oldSyncToken: null, - nextSyncToken, - catchingUp: false, - fromCache: true, - }; - - const data = { - next_batch: nextSyncToken, - rooms: savedSync.roomsData, - groups: savedSync.groupsData, - account_data: { - events: savedSync.accountData, - }, - }; - - try { - await this._processSyncResponse(syncEventData, data); - } catch (e) { - logger.error("Error processing cached sync", e.stack || e); - } - - // Don't emit a prepared if we've bailed because the store is invalid: - // in this case the client will not be usable until stopped & restarted - // so this would be useless and misleading. - if (!this._storeIsInvalid) { - this._updateSyncState("PREPARED", syncEventData); - } -}; - -/** - * Invoke me to do /sync calls - * @param {Object} syncOptions - * @param {string} syncOptions.filterId - * @param {boolean} syncOptions.hasSyncedBefore - */ -SyncApi.prototype._sync = async function(syncOptions) { - const client = this.client; - - if (!this._running) { - debuglog("Sync no longer running: exiting."); - if (this._connectionReturnedDefer) { - this._connectionReturnedDefer.reject(); - this._connectionReturnedDefer = null; - } - this._updateSyncState("STOPPED"); - return; - } - - const syncToken = client.store.getSyncToken(); - - let data; - try { - //debuglog('Starting sync since=' + syncToken); - if (this._currentSyncRequest === null) { - this._currentSyncRequest = this._doSyncRequest(syncOptions, syncToken); - } - data = await this._currentSyncRequest; - } catch (e) { - this._onSyncError(e, syncOptions); - return; - } finally { - this._currentSyncRequest = null; - } - - //debuglog('Completed sync, next_batch=' + data.next_batch); - - // set the sync token NOW *before* processing the events. We do this so - // if something barfs on an event we can skip it rather than constantly - // polling with the same token. - client.store.setSyncToken(data.next_batch); - - // Reset after a successful sync - this._failedSyncCount = 0; - - await client.store.setSyncData(data); - - const syncEventData = { - oldSyncToken: syncToken, - nextSyncToken: data.next_batch, - catchingUp: this._catchingUp, - }; - - if (this.opts.crypto) { - // tell the crypto module we're about to process a sync - // response - await this.opts.crypto.onSyncWillProcess(syncEventData); - } - - try { - await this._processSyncResponse(syncEventData, data); - } catch (e) { - // log the exception with stack if we have it, else fall back - // to the plain description - logger.error("Caught /sync error", e.stack || e); - - // Emit the exception for client handling - this.client.emit("sync.unexpectedError", e); - } - - // update this as it may have changed - syncEventData.catchingUp = this._catchingUp; - - // emit synced events - if (!syncOptions.hasSyncedBefore) { - this._updateSyncState("PREPARED", syncEventData); - syncOptions.hasSyncedBefore = true; - } - - // tell the crypto module to do its processing. It may block (to do a - // /keys/changes request). - if (this.opts.crypto) { - await this.opts.crypto.onSyncCompleted(syncEventData); - } - - // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates - this._updateSyncState("SYNCING", syncEventData); - - if (client.store.wantsSave()) { - // We always save the device list (if it's dirty) before saving the sync data: - // this means we know the saved device list data is at least as fresh as the - // stored sync data which means we don't have to worry that we may have missed - // device changes. We can also skip the delay since we're not calling this very - // frequently (and we don't really want to delay the sync for it). - if (this.opts.crypto) { - await this.opts.crypto.saveDeviceList(0); - } - - // tell databases that everything is now in a consistent state and can be saved. - client.store.save(); - } - - // Begin next sync - this._sync(syncOptions); -}; - -SyncApi.prototype._doSyncRequest = function(syncOptions, syncToken) { - const qps = this._getSyncParams(syncOptions, syncToken); - return this.client.http.authedRequest( - undefined, "GET", "/sync", qps, undefined, - qps.timeout + BUFFER_PERIOD_MS, - ); -}; - -SyncApi.prototype._getSyncParams = function(syncOptions, syncToken) { - let pollTimeout = this.opts.pollTimeout; - - if (this.getSyncState() !== 'SYNCING' || this._catchingUp) { - // unless we are happily syncing already, we want the server to return - // as quickly as possible, even if there are no events queued. This - // serves two purposes: - // - // * When the connection dies, we want to know asap when it comes back, - // so that we can hide the error from the user. (We don't want to - // have to wait for an event or a timeout). - // - // * We want to know if the server has any to_device messages queued up - // for us. We do that by calling it with a zero timeout until it - // doesn't give us any more to_device messages. - this._catchingUp = true; - pollTimeout = 0; - } - - let filterId = syncOptions.filterId; - if (this.client.isGuest() && !filterId) { - filterId = this._getGuestFilter(); - } - - const qps = { - filter: filterId, - timeout: pollTimeout, - }; - - if (this.opts.disablePresence) { - qps.set_presence = "offline"; - } - - if (syncToken) { - qps.since = syncToken; - } else { - // use a cachebuster for initialsyncs, to make sure that - // we don't get a stale sync - // (https://github.com/vector-im/vector-web/issues/1354) - qps._cacheBuster = Date.now(); - } - - if (this.getSyncState() == 'ERROR' || this.getSyncState() == 'RECONNECTING') { - // we think the connection is dead. If it comes back up, we won't know - // about it till /sync returns. If the timeout= is high, this could - // be a long time. Set it to 0 when doing retries so we don't have to wait - // for an event or a timeout before emiting the SYNCING event. - qps.timeout = 0; - } - - return qps; -}; - -SyncApi.prototype._onSyncError = function(err, syncOptions) { - if (!this._running) { - debuglog("Sync no longer running: exiting"); - if (this._connectionReturnedDefer) { - this._connectionReturnedDefer.reject(); - this._connectionReturnedDefer = null; - } - this._updateSyncState("STOPPED"); - return; - } - - logger.error("/sync error %s", err); - logger.error(err); - - if (this._shouldAbortSync(err)) { - return; - } - - this._failedSyncCount++; - logger.log('Number of consecutive failed sync requests:', this._failedSyncCount); - - debuglog("Starting keep-alive"); - // Note that we do *not* mark the sync connection as - // lost yet: we only do this if a keepalive poke - // fails, since long lived HTTP connections will - // go away sometimes and we shouldn't treat this as - // erroneous. We set the state to 'reconnecting' - // instead, so that clients can observe this state - // if they wish. - this._startKeepAlives().then((connDidFail) => { - // Only emit CATCHUP if we detected a connectivity error: if we didn't, - // it's quite likely the sync will fail again for the same reason and we - // want to stay in ERROR rather than keep flip-flopping between ERROR - // and CATCHUP. - if (connDidFail && this.getSyncState() === 'ERROR') { - this._updateSyncState("CATCHUP", { - oldSyncToken: null, - nextSyncToken: null, - catchingUp: true, - }); - } - this._sync(syncOptions); - }); - - this._currentSyncRequest = null; - // Transition from RECONNECTING to ERROR after a given number of failed syncs - this._updateSyncState( - this._failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? - "ERROR" : "RECONNECTING", - { error: err }, - ); -}; - -/** - * Process data returned from a sync response and propagate it - * into the model objects - * - * @param {Object} syncEventData Object containing sync tokens associated with this sync - * @param {Object} data The response from /sync - */ -SyncApi.prototype._processSyncResponse = async function( - syncEventData, data, -) { - const client = this.client; - const self = this; - - // data looks like: - // { - // next_batch: $token, - // presence: { events: [] }, - // account_data: { events: [] }, - // device_lists: { changed: ["@user:server", ... ]}, - // to_device: { events: [] }, - // device_one_time_keys_count: { signed_curve25519: 42 }, - // rooms: { - // invite: { - // $roomid: { - // invite_state: { events: [] } - // } - // }, - // join: { - // $roomid: { - // state: { events: [] }, - // timeline: { events: [], prev_batch: $token, limited: true }, - // ephemeral: { events: [] }, - // summary: { - // m.heroes: [ $user_id ], - // m.joined_member_count: $count, - // m.invited_member_count: $count - // }, - // account_data: { events: [] }, - // unread_notifications: { - // highlight_count: 0, - // notification_count: 0, - // } - // } - // }, - // leave: { - // $roomid: { - // state: { events: [] }, - // timeline: { events: [], prev_batch: $token } - // } - // } - // }, - // groups: { - // invite: { - // $groupId: { - // inviter: $inviter, - // profile: { - // avatar_url: $avatarUrl, - // name: $groupName, - // }, - // }, - // }, - // join: {}, - // leave: {}, - // }, - // } - - // TODO-arch: - // - Each event we pass through needs to be emitted via 'event', can we - // do this in one place? - // - The isBrandNewRoom boilerplate is boilerplatey. - - // handle presence events (User objects) - if (data.presence && Array.isArray(data.presence.events)) { - data.presence.events.map(client.getEventMapper()).forEach( - function(presenceEvent) { - let user = client.store.getUser(presenceEvent.getSender()); - if (user) { - user.setPresenceEvent(presenceEvent); - } else { - user = createNewUser(client, presenceEvent.getSender()); - user.setPresenceEvent(presenceEvent); - client.store.storeUser(user); - } - client.emit("event", presenceEvent); - }); - } - - // handle non-room account_data - if (data.account_data && Array.isArray(data.account_data.events)) { - const events = data.account_data.events.map(client.getEventMapper()); - const prevEventsMap = events.reduce((m, c) => { - m[c.getId()] = client.store.getAccountData(c.getType()); - return m; - }, {}); - client.store.storeAccountDataEvents(events); - events.forEach( - function(accountDataEvent) { - // Honour push rules that come down the sync stream but also - // honour push rules that were previously cached. Base rules - // will be updated when we receive push rules via getPushRules - // (see SyncApi.prototype.sync) before syncing over the network. - if (accountDataEvent.getType() === 'm.push_rules') { - const rules = accountDataEvent.getContent(); - client.pushRules = PushProcessor.rewriteDefaultRules(rules); - } - const prevEvent = prevEventsMap[accountDataEvent.getId()]; - client.emit("accountData", accountDataEvent, prevEvent); - return accountDataEvent; - }, - ); - } - - // handle to-device events - if (data.to_device && Array.isArray(data.to_device.events) && - data.to_device.events.length > 0 - ) { - const cancelledKeyVerificationTxns = []; - data.to_device.events - .map(client.getEventMapper()) - .map((toDeviceEvent) => { // map is a cheap inline forEach - // We want to flag m.key.verification.start events as cancelled - // if there's an accompanying m.key.verification.cancel event, so - // we pull out the transaction IDs from the cancellation events - // so we can flag the verification events as cancelled in the loop - // below. - if (toDeviceEvent.getType() === "m.key.verification.cancel") { - const txnId = toDeviceEvent.getContent()['transaction_id']; - if (txnId) { - cancelledKeyVerificationTxns.push(txnId); - } - } - - // as mentioned above, .map is a cheap inline forEach, so return - // the unmodified event. - return toDeviceEvent; - }) - .forEach( - function(toDeviceEvent) { - const content = toDeviceEvent.getContent(); - if ( - toDeviceEvent.getType() == "m.room.message" && - content.msgtype == "m.bad.encrypted" - ) { - // the mapper already logged a warning. - logger.log( - 'Ignoring undecryptable to-device event from ' + - toDeviceEvent.getSender(), - ); - return; - } - - if (toDeviceEvent.getType() === "m.key.verification.start" - || toDeviceEvent.getType() === "m.key.verification.request") { - const txnId = content['transaction_id']; - if (cancelledKeyVerificationTxns.includes(txnId)) { - toDeviceEvent.flagCancelled(); - } - } - - client.emit("toDeviceEvent", toDeviceEvent); - }, - ); - } else { - // no more to-device events: we can stop polling with a short timeout. - this._catchingUp = false; - } - - if (data.groups) { - if (data.groups.invite) { - this._processGroupSyncEntry(data.groups.invite, 'invite'); - } - - if (data.groups.join) { - this._processGroupSyncEntry(data.groups.join, 'join'); - } - - if (data.groups.leave) { - this._processGroupSyncEntry(data.groups.leave, 'leave'); - } - } - - // the returned json structure is a bit crap, so make it into a - // nicer form (array) after applying sanity to make sure we don't fail - // on missing keys (on the off chance) - let inviteRooms = []; - let joinRooms = []; - let leaveRooms = []; - - if (data.rooms) { - if (data.rooms.invite) { - inviteRooms = this._mapSyncResponseToRoomArray(data.rooms.invite); - } - if (data.rooms.join) { - joinRooms = this._mapSyncResponseToRoomArray(data.rooms.join); - } - if (data.rooms.leave) { - leaveRooms = this._mapSyncResponseToRoomArray(data.rooms.leave); - } - } - - this._notifEvents = []; - - // Handle invites - inviteRooms.forEach(function(inviteObj) { - const room = inviteObj.room; - const stateEvents = - self._mapSyncEventsFormat(inviteObj.invite_state, room); - - self._processRoomEvents(room, stateEvents); - if (inviteObj.isBrandNewRoom) { - room.recalculate(); - client.store.storeRoom(room); - client.emit("Room", room); - } - stateEvents.forEach(function(e) { - client.emit("event", e); - }); - room.updateMyMembership("invite"); - }); - - // Handle joins - await utils.promiseMapSeries(joinRooms, async function(joinObj) { - const room = joinObj.room; - const stateEvents = self._mapSyncEventsFormat(joinObj.state, room); - // Prevent events from being decrypted ahead of time - // this helps large account to speed up faster - // room::decryptCriticalEvent is in charge of decrypting all the events - // required for a client to function properly - const timelineEvents = self._mapSyncEventsFormat(joinObj.timeline, room, false); - const ephemeralEvents = self._mapSyncEventsFormat(joinObj.ephemeral); - const accountDataEvents = self._mapSyncEventsFormat(joinObj.account_data); - - const encrypted = client.isRoomEncrypted(room.roomId); - // we do this first so it's correct when any of the events fire - if (joinObj.unread_notifications) { - room.setUnreadNotificationCount( - 'total', joinObj.unread_notifications.notification_count, - ); - - // We track unread notifications ourselves in encrypted rooms, so don't - // bother setting it here. We trust our calculations better than the - // server's for this case, and therefore will assume that our non-zero - // count is accurate. - if (!encrypted - || (encrypted && room.getUnreadNotificationCount('highlight') <= 0)) { - room.setUnreadNotificationCount( - 'highlight', joinObj.unread_notifications.highlight_count, - ); - } - } - - joinObj.timeline = joinObj.timeline || {}; - - if (joinObj.isBrandNewRoom) { - // set the back-pagination token. Do this *before* adding any - // events so that clients can start back-paginating. - room.getLiveTimeline().setPaginationToken( - joinObj.timeline.prev_batch, EventTimeline.BACKWARDS); - } else if (joinObj.timeline.limited) { - let limited = true; - - // we've got a limited sync, so we *probably* have a gap in the - // timeline, so should reset. But we might have been peeking or - // paginating and already have some of the events, in which - // case we just want to append any subsequent events to the end - // of the existing timeline. - // - // This is particularly important in the case that we already have - // *all* of the events in the timeline - in that case, if we reset - // the timeline, we'll end up with an entirely empty timeline, - // which we'll try to paginate but not get any new events (which - // will stop us linking the empty timeline into the chain). - // - for (let i = timelineEvents.length - 1; i >= 0; i--) { - const eventId = timelineEvents[i].getId(); - if (room.getTimelineForEvent(eventId)) { - debuglog("Already have event " + eventId + " in limited " + - "sync - not resetting"); - limited = false; - - // we might still be missing some of the events before i; - // we don't want to be adding them to the end of the - // timeline because that would put them out of order. - timelineEvents.splice(0, i); - - // XXX: there's a problem here if the skipped part of the - // timeline modifies the state set in stateEvents, because - // we'll end up using the state from stateEvents rather - // than the later state from timelineEvents. We probably - // need to wind stateEvents forward over the events we're - // skipping. - - break; - } - } - - if (limited) { - self._deregisterStateListeners(room); - room.resetLiveTimeline( - joinObj.timeline.prev_batch, - self.opts.canResetEntireTimeline(room.roomId) ? - null : syncEventData.oldSyncToken, - ); - - // We have to assume any gap in any timeline is - // reason to stop incrementally tracking notifications and - // reset the timeline. - client.resetNotifTimelineSet(); - - self._registerStateListeners(room); - } - } - - self._processRoomEvents(room, stateEvents, - timelineEvents, syncEventData.fromCache); - - // set summary after processing events, - // because it will trigger a name calculation - // which needs the room state to be up to date - if (joinObj.summary) { - room.setSummary(joinObj.summary); - } - - // we deliberately don't add ephemeral events to the timeline - room.addEphemeralEvents(ephemeralEvents); - - // we deliberately don't add accountData to the timeline - room.addAccountData(accountDataEvents); - - room.recalculate(); - if (joinObj.isBrandNewRoom) { - client.store.storeRoom(room); - client.emit("Room", room); - } - - self._processEventsForNotifs(room, timelineEvents); - - async function processRoomEvent(e) { - client.emit("event", e); - if (e.isState() && e.getType() == "m.room.encryption" && self.opts.crypto) { - await self.opts.crypto.onCryptoEvent(e); - } - if (e.isState() && e.getType() === "im.vector.user_status") { - let user = client.store.getUser(e.getStateKey()); - if (user) { - user.unstable_updateStatusMessage(e); - } else { - user = createNewUser(client, e.getStateKey()); - user.unstable_updateStatusMessage(e); - client.store.storeUser(user); - } - } - } - - await utils.promiseMapSeries(stateEvents, processRoomEvent); - await utils.promiseMapSeries(timelineEvents, processRoomEvent); - ephemeralEvents.forEach(function(e) { - client.emit("event", e); - }); - accountDataEvents.forEach(function(e) { - client.emit("event", e); - }); - - room.updateMyMembership("join"); - - // Decrypt only the last message in all rooms to make sure we can generate a preview - // And decrypt all events after the recorded read receipt to ensure an accurate - // notification count - room.decryptCriticalEvents(); - }); - - // Handle leaves (e.g. kicked rooms) - leaveRooms.forEach(function(leaveObj) { - const room = leaveObj.room; - const stateEvents = - self._mapSyncEventsFormat(leaveObj.state, room); - const timelineEvents = - self._mapSyncEventsFormat(leaveObj.timeline, room); - const accountDataEvents = - self._mapSyncEventsFormat(leaveObj.account_data); - - self._processRoomEvents(room, stateEvents, timelineEvents); - room.addAccountData(accountDataEvents); - - room.recalculate(); - if (leaveObj.isBrandNewRoom) { - client.store.storeRoom(room); - client.emit("Room", room); - } - - self._processEventsForNotifs(room, timelineEvents); - - stateEvents.forEach(function(e) { - client.emit("event", e); - }); - timelineEvents.forEach(function(e) { - client.emit("event", e); - }); - accountDataEvents.forEach(function(e) { - client.emit("event", e); - }); - - room.updateMyMembership("leave"); - }); - - // update the notification timeline, if appropriate. - // we only do this for live events, as otherwise we can't order them sanely - // in the timeline relative to ones paginated in by /notifications. - // XXX: we could fix this by making EventTimeline support chronological - // ordering... but it doesn't, right now. - if (syncEventData.oldSyncToken && this._notifEvents.length) { - this._notifEvents.sort(function(a, b) { - return a.getTs() - b.getTs(); - }); - this._notifEvents.forEach(function(event) { - client.getNotifTimelineSet().addLiveEvent(event); - }); - } - - // Handle device list updates - if (data.device_lists) { - if (this.opts.crypto) { - await this.opts.crypto.handleDeviceListChanges( - syncEventData, data.device_lists, - ); - } else { - // FIXME if we *don't* have a crypto module, we still need to - // invalidate the device lists. But that would require a - // substantial bit of rework :/. - } - } - - // Handle one_time_keys_count - if (this.opts.crypto && data.device_one_time_keys_count) { - const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0; - this.opts.crypto.updateOneTimeKeyCount(currentCount); - } - if (this.opts.crypto && data["org.matrix.msc2732.device_unused_fallback_key_types"]) { - // The presence of device_unused_fallback_key_types indicates that the - // server supports fallback keys. If there's no unused - // signed_curve25519 fallback key we need a new one. - const unusedFallbackKeys = data["org.matrix.msc2732.device_unused_fallback_key_types"]; - this.opts.crypto.setNeedsNewFallback( - unusedFallbackKeys instanceof Array && - !unusedFallbackKeys.includes("signed_curve25519"), - ); - } -}; - -/** - * Starts polling the connectivity check endpoint - * @param {number} delay How long to delay until the first poll. - * defaults to a short, randomised interval (to prevent - * tightlooping if /versions succeeds but /sync etc. fail). - * @return {promise} which resolves once the connection returns - */ -SyncApi.prototype._startKeepAlives = function(delay) { - if (delay === undefined) { - delay = 2000 + Math.floor(Math.random() * 5000); - } - - if (this._keepAliveTimer !== null) { - clearTimeout(this._keepAliveTimer); - } - const self = this; - if (delay > 0) { - self._keepAliveTimer = setTimeout( - self._pokeKeepAlive.bind(self), - delay, - ); - } else { - self._pokeKeepAlive(); - } - if (!this._connectionReturnedDefer) { - this._connectionReturnedDefer = utils.defer(); - } - return this._connectionReturnedDefer.promise; -}; - -/** - * Make a dummy call to /_matrix/client/versions, to see if the HS is - * reachable. - * - * On failure, schedules a call back to itself. On success, resolves - * this._connectionReturnedDefer. - * - * @param {bool} connDidFail True if a connectivity failure has been detected. Optional. - */ -SyncApi.prototype._pokeKeepAlive = function(connDidFail) { - if (connDidFail === undefined) connDidFail = false; - const self = this; - function success() { - clearTimeout(self._keepAliveTimer); - if (self._connectionReturnedDefer) { - self._connectionReturnedDefer.resolve(connDidFail); - self._connectionReturnedDefer = null; - } - } - - this.client.http.request( - undefined, // callback - "GET", "/_matrix/client/versions", - undefined, // queryParams - undefined, // data - { - prefix: '', - localTimeoutMs: 15 * 1000, - }, - ).then(function() { - success(); - }, function(err) { - if (err.httpStatus == 400 || err.httpStatus == 404) { - // treat this as a success because the server probably just doesn't - // support /versions: point is, we're getting a response. - // We wait a short time though, just in case somehow the server - // is in a mode where it 400s /versions responses and sync etc. - // responses fail, this will mean we don't hammer in a loop. - self._keepAliveTimer = setTimeout(success, 2000); - } else { - connDidFail = true; - self._keepAliveTimer = setTimeout( - self._pokeKeepAlive.bind(self, connDidFail), - 5000 + Math.floor(Math.random() * 5000), - ); - // A keepalive has failed, so we emit the - // error state (whether or not this is the - // first failure). - // Note we do this after setting the timer: - // this lets the unit tests advance the mock - // clock when they get the error. - self._updateSyncState("ERROR", { error: err }); - } - }); -}; - -/** - * @param {Object} groupsSection Groups section object, eg. response.groups.invite - * @param {string} sectionName Which section this is ('invite', 'join' or 'leave') - */ -SyncApi.prototype._processGroupSyncEntry = function(groupsSection, sectionName) { - // Processes entries from 'groups' section of the sync stream - for (const groupId of Object.keys(groupsSection)) { - const groupInfo = groupsSection[groupId]; - let group = this.client.store.getGroup(groupId); - const isBrandNew = group === null; - if (group === null) { - group = this.createGroup(groupId); - } - if (groupInfo.profile) { - group.setProfile( - groupInfo.profile.name, groupInfo.profile.avatar_url, - ); - } - if (groupInfo.inviter) { - group.setInviter({ userId: groupInfo.inviter }); - } - group.setMyMembership(sectionName); - if (isBrandNew) { - // Now we've filled in all the fields, emit the Group event - this.client.emit("Group", group); - } - } -}; - -/** - * @param {Object} obj - * @return {Object[]} - */ -SyncApi.prototype._mapSyncResponseToRoomArray = function(obj) { - // Maps { roomid: {stuff}, roomid: {stuff} } - // to - // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}] - const client = this.client; - const self = this; - return Object.keys(obj).map(function(roomId) { - const arrObj = obj[roomId]; - let room = client.store.getRoom(roomId); - let isBrandNewRoom = false; - if (!room) { - room = self.createRoom(roomId); - isBrandNewRoom = true; - } - arrObj.room = room; - arrObj.isBrandNewRoom = isBrandNewRoom; - return arrObj; - }); -}; - -/** - * @param {Object} obj - * @param {Room} room - * @param {bool} decrypt - * @return {MatrixEvent[]} - */ -SyncApi.prototype._mapSyncEventsFormat = function(obj, room, decrypt = true) { - if (!obj || !Array.isArray(obj.events)) { - return []; - } - const mapper = this.client.getEventMapper({ decrypt }); - return obj.events.map(function(e) { - if (room) { - e.room_id = room.roomId; - } - return mapper(e); - }); -}; - -/** - * @param {Room} room - */ -SyncApi.prototype._resolveInvites = function(room) { - if (!room || !this.opts.resolveInvitesToProfiles) { - return; - } - const client = this.client; - // For each invited room member we want to give them a displayname/avatar url - // if they have one (the m.room.member invites don't contain this). - room.getMembersWithMembership("invite").forEach(function(member) { - if (member._requestedProfileInfo) { - return; - } - member._requestedProfileInfo = true; - // try to get a cached copy first. - const user = client.getUser(member.userId); - let promise; - if (user) { - promise = Promise.resolve({ - avatar_url: user.avatarUrl, - displayname: user.displayName, - }); - } else { - promise = client.getProfileInfo(member.userId); - } - promise.then(function(info) { - // slightly naughty by doctoring the invite event but this means all - // the code paths remain the same between invite/join display name stuff - // which is a worthy trade-off for some minor pollution. - const inviteEvent = member.events.member; - if (inviteEvent.getContent().membership !== "invite") { - // between resolving and now they have since joined, so don't clobber - return; - } - inviteEvent.getContent().avatar_url = info.avatar_url; - inviteEvent.getContent().displayname = info.displayname; - // fire listeners - member.setMembershipEvent(inviteEvent, room.currentState); - }, function(err) { - // OH WELL. - }); - }); -}; - -/** - * @param {Room} room - * @param {MatrixEvent[]} stateEventList A list of state events. This is the state - * at the *START* of the timeline list if it is supplied. - * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index - * @param {boolean} fromCache whether the sync response came from cache - * is earlier in time. Higher index is later. - */ -SyncApi.prototype._processRoomEvents = function(room, stateEventList, - timelineEventList, fromCache) { - // If there are no events in the timeline yet, initialise it with - // the given state events - const liveTimeline = room.getLiveTimeline(); - const timelineWasEmpty = liveTimeline.getEvents().length == 0; - if (timelineWasEmpty) { - // Passing these events into initialiseState will freeze them, so we need - // to compute and cache the push actions for them now, otherwise sync dies - // with an attempt to assign to read only property. - // XXX: This is pretty horrible and is assuming all sorts of behaviour from - // these functions that it shouldn't be. We should probably either store the - // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise - // find some solution where MatrixEvents are immutable but allow for a cache - // field. - for (const ev of stateEventList) { - this.client.getPushActionsForEvent(ev); - } - liveTimeline.initialiseState(stateEventList); - } - - this._resolveInvites(room); - - // recalculate the room name at this point as adding events to the timeline - // may make notifications appear which should have the right name. - // XXX: This looks suspect: we'll end up recalculating the room once here - // and then again after adding events (_processSyncResponse calls it after - // calling us) even if no state events were added. It also means that if - // one of the room events in timelineEventList is something that needs - // a recalculation (like m.room.name) we won't recalculate until we've - // finished adding all the events, which will cause the notification to have - // the old room name rather than the new one. - room.recalculate(); - - // If the timeline wasn't empty, we process the state events here: they're - // defined as updates to the state before the start of the timeline, so this - // starts to roll the state forward. - // XXX: That's what we *should* do, but this can happen if we were previously - // peeking in a room, in which case we obviously do *not* want to add the - // state events here onto the end of the timeline. Historically, the js-sdk - // has just set these new state events on the old and new state. This seems - // very wrong because there could be events in the timeline that diverge the - // state, in which case this is going to leave things out of sync. However, - // for now I think it;s best to behave the same as the code has done previously. - if (!timelineWasEmpty) { - // XXX: As above, don't do this... - //room.addLiveEvents(stateEventList || []); - // Do this instead... - room.oldState.setStateEvents(stateEventList || []); - room.currentState.setStateEvents(stateEventList || []); - } - // execute the timeline events. This will continue to diverge the current state - // if the timeline has any state events in it. - // This also needs to be done before running push rules on the events as they need - // to be decorated with sender etc. - room.addLiveEvents(timelineEventList || [], null, fromCache); -}; - -/** - * Takes a list of timelineEvents and adds and adds to _notifEvents - * as appropriate. - * This must be called after the room the events belong to has been stored. - * - * @param {Room} room - * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index - * is earlier in time. Higher index is later. - */ -SyncApi.prototype._processEventsForNotifs = function(room, timelineEventList) { - // gather our notifications into this._notifEvents - if (this.client.getNotifTimelineSet()) { - for (let i = 0; i < timelineEventList.length; i++) { - const pushActions = this.client.getPushActionsForEvent(timelineEventList[i]); - if (pushActions && pushActions.notify && - pushActions.tweaks && pushActions.tweaks.highlight) { - this._notifEvents.push(timelineEventList[i]); - } - } - } -}; - -/** - * @return {string} - */ -SyncApi.prototype._getGuestFilter = function() { - // 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 "{}"; -}; - -/** - * Sets the sync state and emits an event to say so - * @param {String} newState The new state string - * @param {Object} data Object of additional data to emit in the event - */ -SyncApi.prototype._updateSyncState = function(newState, data) { - const old = this._syncState; - this._syncState = newState; - this._syncStateData = data; - this.client.emit("sync", this._syncState, old, data); -}; - -/** - * Event handler for the 'online' event - * This event is generally unreliable and precise behaviour - * varies between browsers, so we poll for connectivity too, - * but this might help us reconnect a little faster. - */ -SyncApi.prototype._onOnline = function() { - debuglog("Browser thinks we are back online"); - this._startKeepAlives(0); -}; - -function createNewUser(client, userId) { - const user = new User(userId); - client.reEmitter.reEmit(user, [ - "User.avatarUrl", "User.displayName", "User.presence", - "User.currentlyActive", "User.lastPresenceTs", - ]); - return user; -} - diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 000000000..831c7b084 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,1745 @@ +/* +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. +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: + * 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 + * an alternative syncing API, we may want to have a proper syncing interface + * for HTTP and WS at some point. + */ + +import { User } from "./models/user"; +import { NotificationCountType, Room } from "./models/room"; +import { Group } from "./models/group"; +import * as utils from "./utils"; +import { IDeferred } from "./utils"; +import { Filter } from "./filter"; +import { EventTimeline } from "./models/event-timeline"; +import { PushProcessor } from "./pushprocessor"; +import { logger } from './logger'; +import { InvalidStoreError } from './errors'; +import { IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client"; +import { SyncState } from "./sync.api"; +import { + Category, + IInvitedRoom, + IInviteState, + IJoinedRoom, + ILeftRoom, + IStateEvent, + IRoomEvent, + IStrippedState, + ISyncResponse, + ITimeline, + IEphemeral, + IMinimalEvent, +} from "./sync-accumulator"; +import { MatrixEvent } from "./models/event"; +import { MatrixError } from "./http-api"; +import { ISavedSync } from "./store"; + +const DEBUG = true; + +// /sync requests allow you to set a timeout= but the request may continue +// beyond that and wedge forever, so we need to track how long we are willing +// to keep open the connection. This constant is *ADDED* to the timeout= value +// to determine the max time we're willing to wait. +const BUFFER_PERIOD_MS = 80 * 1000; + +// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed +// to RECONNECTING. This is needed to inform the client of server issues when the +// keepAlive is successful but the server /sync fails. +const FAILED_SYNC_ERROR_THRESHOLD = 3; + +function getFilterName(userId: string, suffix?: string): string { + // scope this on the user ID because people may login on many accounts + // and they all need to be stored! + return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : ""); +} + +function debuglog(...params) { + if (!DEBUG) { + return; + } + logger.log(...params); +} + +interface ISyncOptions { + filterId?: string; + hasSyncedBefore?: boolean; +} + +export interface ISyncStateData { + error?: Error; + oldSyncToken?: string; + nextSyncToken?: string; + catchingUp?: boolean; + fromCache?: boolean; +} + +interface ISyncParams { + filter?: string; + timeout: number; + since?: string; + // eslint-disable-next-line camelcase + full_state?: boolean; + // eslint-disable-next-line camelcase + set_presence?: "offline" | "online" | "unavailable"; + _cacheBuster?: string | number; // not part of the API itself +} + +// http-api mangles an abort method onto its promises +interface IRequestPromise extends Promise { + abort(): void; +} + +type WrappedRoom = T & { + room: Room; + isBrandNewRoom: boolean; +}; + +/** + * Internal class - unstable. + * Construct an entity which is able to sync with a homeserver. + * @constructor + * @param {MatrixClient} client The matrix client instance to use. + * @param {Object} opts Config options + * @param {module:crypto=} opts.crypto Crypto manager + * @param {Function=} opts.canResetEntireTimeline A function which is called + * with a room ID and returns a boolean. It should return 'true' if the SDK can + * SAFELY remove events from this room. It may not be safe to remove events if + * there are other references to the timelines for this room. + * Default: returns false. + * @param {Boolean=} opts.disablePresence True to perform syncing without automatically + * updating presence. + */ +export class SyncApi { + private _peekRoom: Room = null; + private currentSyncRequest: IRequestPromise = null; + private syncState: SyncState = null; + private syncStateData: ISyncStateData = null; // additional data (eg. error object for failed sync) + private catchingUp = false; + private running = false; + private keepAliveTimer: NodeJS.Timeout = null; + private connectionReturnedDefer: IDeferred = null; + private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response + private failedSyncCount = 0; // Number of consecutive failed /sync requests + private storeIsInvalid = false; // flag set if the store needs to be cleared before we can start + + constructor(private readonly client: MatrixClient, private readonly opts: Partial = {}) { + this.opts.initialSyncLimit = this.opts.initialSyncLimit ?? 8; + this.opts.resolveInvitesToProfiles = this.opts.resolveInvitesToProfiles || false; + this.opts.pollTimeout = this.opts.pollTimeout || (30 * 1000); + this.opts.pendingEventOrdering = this.opts.pendingEventOrdering || PendingEventOrdering.Chronological; + + if (!opts.canResetEntireTimeline) { + opts.canResetEntireTimeline = (roomId: string) => { + return false; + }; + } + + if (client.getNotifTimelineSet()) { + client.reEmitter.reEmit(client.getNotifTimelineSet(), + ["Room.timeline", "Room.timelineReset"]); + } + } + + /** + * @param {string} roomId + * @return {Room} + */ + public createRoom(roomId: string): Room { + const client = this.client; + const { + timelineSupport, + unstableClientRelationAggregation, + } = client; + const room = new Room(roomId, client, client.getUserId(), { + lazyLoadMembers: this.opts.lazyLoadMembers, + pendingEventOrdering: this.opts.pendingEventOrdering, + timelineSupport, + unstableClientRelationAggregation, + }); + client.reEmitter.reEmit(room, ["Room.name", "Room.timeline", + "Room.redaction", + "Room.redactionCancelled", + "Room.receipt", "Room.tags", + "Room.timelineReset", + "Room.localEchoUpdated", + "Room.accountData", + "Room.myMembership", + "Room.replaceEvent", + ]); + this.registerStateListeners(room); + return room; + } + + /** + * @param {string} groupId + * @return {Group} + */ + public createGroup(groupId: string): Group { + const client = this.client; + const group = new Group(groupId); + client.reEmitter.reEmit(group, ["Group.profile", "Group.myMembership"]); + client.store.storeGroup(group); + return group; + } + + /** + * @param {Room} room + * @private + */ + private registerStateListeners(room: Room): void { + const client = this.client; + // we need to also re-emit room state and room member events, so hook it up + // to the client now. We need to add a listener for RoomState.members in + // order to hook them correctly. (TODO: find a better way?) + client.reEmitter.reEmit(room.currentState, [ + "RoomState.events", "RoomState.members", "RoomState.newMember", + ]); + room.currentState.on("RoomState.newMember", function(event, state, member) { + member.user = client.getUser(member.userId); + client.reEmitter.reEmit( + member, + [ + "RoomMember.name", "RoomMember.typing", "RoomMember.powerLevel", + "RoomMember.membership", + ], + ); + }); + } + + /** + * @param {Room} room + * @private + */ + private deregisterStateListeners(room: Room): void { + // could do with a better way of achieving this. + room.currentState.removeAllListeners("RoomState.events"); + room.currentState.removeAllListeners("RoomState.members"); + room.currentState.removeAllListeners("RoomState.newMember"); + } + + /** + * Sync rooms the user has left. + * @return {Promise} Resolved when they've been added to the store. + */ + public syncLeftRooms() { + const client = this.client; + + // grab a filter with limit=1 and include_leave=true + const filter = new Filter(this.client.credentials.userId); + filter.setTimelineLimit(1); + filter.setIncludeLeaveRooms(true); + + const localTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS; + const qps: ISyncParams = { + timeout: 0, // don't want to block since this is a single isolated req + }; + + return client.getOrCreateFilter( + getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter, + ).then(function(filterId) { + qps.filter = filterId; + return client.http.authedRequest( + undefined, "GET", "/sync", qps, undefined, localTimeoutMs, + ); + }).then((data) => { + let leaveRooms = []; + if (data.rooms && data.rooms.leave) { + leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); + } + const rooms = []; + leaveRooms.forEach((leaveObj) => { + const room = leaveObj.room; + rooms.push(room); + if (!leaveObj.isBrandNewRoom) { + // the intention behind syncLeftRooms is to add in rooms which were + // *omitted* from the initial /sync. Rooms the user were joined to + // but then left whilst the app is running will appear in this list + // and we do not want to bother with them since they will have the + // current state already (and may get dupe messages if we add + // yet more timeline events!), so skip them. + // NB: When we persist rooms to localStorage this will be more + // complicated... + return; + } + leaveObj.timeline = leaveObj.timeline || {}; + const timelineEvents = + this.mapSyncEventsFormat(leaveObj.timeline, room); + const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); + + // set the back-pagination token. Do this *before* adding any + // events so that clients can start back-paginating. + room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, + EventTimeline.BACKWARDS); + + this.processRoomEvents(room, stateEvents, timelineEvents); + + room.recalculate(); + client.store.storeRoom(room); + client.emit("Room", room); + + this.processEventsForNotifs(room, timelineEvents); + }); + return rooms; + }); + } + + /** + * Peek into a room. This will result in the room in question being synced so it + * is accessible via getRooms(). Live updates for the room will be provided. + * @param {string} roomId The room ID to peek into. + * @return {Promise} A promise which resolves once the room has been added to the + * store. + */ + public peek(roomId: string): Promise { + if (this._peekRoom && this._peekRoom.roomId === roomId) { + return Promise.resolve(this._peekRoom); + } + + const client = this.client; + this._peekRoom = this.createRoom(roomId); + return this.client.roomInitialSync(roomId, 20).then((response) => { + // make sure things are init'd + response.messages = response.messages || {}; + response.messages.chunk = response.messages.chunk || []; + response.state = response.state || []; + + // FIXME: Mostly duplicated from processRoomEvents but not entirely + // because "state" in this API is at the BEGINNING of the chunk + const oldStateEvents = utils.deepCopy(response.state) + .map(client.getEventMapper()); + const stateEvents = response.state.map(client.getEventMapper()); + const messages = response.messages.chunk.map(client.getEventMapper()); + + // XXX: copypasted from /sync until we kill off this + // minging v1 API stuff) + // handle presence events (User objects) + if (response.presence && Array.isArray(response.presence)) { + response.presence.map(client.getEventMapper()).forEach( + function(presenceEvent) { + let user = client.store.getUser(presenceEvent.getContent().user_id); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(client, presenceEvent.getContent().user_id); + user.setPresenceEvent(presenceEvent); + client.store.storeUser(user); + } + client.emit("event", presenceEvent); + }); + } + + // set the pagination token before adding the events in case people + // fire off pagination requests in response to the Room.timeline + // events. + if (response.messages.start) { + this._peekRoom.oldState.paginationToken = response.messages.start; + } + + // set the state of the room to as it was after the timeline executes + this._peekRoom.oldState.setStateEvents(oldStateEvents); + this._peekRoom.currentState.setStateEvents(stateEvents); + + this.resolveInvites(this._peekRoom); + this._peekRoom.recalculate(); + + // roll backwards to diverge old state. addEventsToTimeline + // will overwrite the pagination token, so make sure it overwrites + // it with the right thing. + this._peekRoom.addEventsToTimeline(messages.reverse(), true, + this._peekRoom.getLiveTimeline(), + response.messages.start); + + client.store.storeRoom(this._peekRoom); + client.emit("Room", this._peekRoom); + + this.peekPoll(this._peekRoom); + return this._peekRoom; + }); + } + + /** + * Stop polling for updates in the peeked room. NOPs if there is no room being + * peeked. + */ + public stopPeeking(): void { + this._peekRoom = null; + } + + /** + * Do a peek room poll. + * @param {Room} peekRoom + * @param {string?} token from= token + */ + private peekPoll(peekRoom: Room, token?: string): void { + if (this._peekRoom !== peekRoom) { + debuglog("Stopped peeking in room %s", peekRoom.roomId); + return; + } + + // FIXME: gut wrenching; hard-coded timeout values + this.client.http.authedRequest(undefined, "GET", "/events", { + room_id: peekRoom.roomId, + timeout: 30 * 1000, + from: token, + }, undefined, 50 * 1000).then((res) => { + if (this._peekRoom !== peekRoom) { + debuglog("Stopped peeking in room %s", peekRoom.roomId); + return; + } + // We have a problem that we get presence both from /events and /sync + // however, /sync only returns presence for users in rooms + // you're actually joined to. + // in order to be sure to get presence for all of the users in the + // peeked room, we handle presence explicitly here. This may result + // in duplicate presence events firing for some users, which is a + // performance drain, but such is life. + // XXX: copypasted from /sync until we can kill this minging v1 stuff. + + res.chunk.filter(function(e) { + return e.type === "m.presence"; + }).map(this.client.getEventMapper()).forEach((presenceEvent) => { + let user = this.client.store.getUser(presenceEvent.getContent().user_id); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(this.client, presenceEvent.getContent().user_id); + user.setPresenceEvent(presenceEvent); + this.client.store.storeUser(user); + } + this.client.emit("event", presenceEvent); + }); + + // strip out events which aren't for the given room_id (e.g presence) + // and also ephemeral events (which we're assuming is anything without + // and event ID because the /events API doesn't separate them). + const events = res.chunk.filter(function(e) { + return e.room_id === peekRoom.roomId && e.event_id; + }).map(this.client.getEventMapper()); + + peekRoom.addLiveEvents(events); + this.peekPoll(peekRoom, res.end); + }, (err) => { + logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err); + setTimeout(() => { + this.peekPoll(peekRoom, token); + }, 30 * 1000); + }); + } + + /** + * Returns the current state of this sync object + * @see module:client~MatrixClient#event:"sync" + * @return {?String} + */ + public getSyncState(): SyncState { + return this.syncState; + } + + /** + * Returns the additional data object associated with + * the current sync state, or null if there is no + * such data. + * Sync errors, if available, are put in the 'error' key of + * this object. + * @return {?Object} + */ + public getSyncStateData(): ISyncStateData { + return this.syncStateData; + } + + public async recoverFromSyncStartupError(savedSyncPromise: Promise, err: Error): Promise { + // Wait for the saved sync to complete - we send the pushrules and filter requests + // before the saved sync has finished so they can run in parallel, but only process + // the results after the saved sync is done. Equivalently, we wait for it to finish + // before reporting failures from these functions. + await savedSyncPromise; + const keepaliveProm = this.startKeepAlives(); + this.updateSyncState(SyncState.Error, { error: err }); + await keepaliveProm; + } + + /** + * Is the lazy loading option different than in previous session? + * @param {boolean} lazyLoadMembers current options for lazy loading + * @return {boolean} whether or not the option has changed compared to the previous session */ + private async wasLazyLoadingToggled(lazyLoadMembers = false): Promise { + // assume it was turned off before + // if we don't know any better + let lazyLoadMembersBefore = false; + const isStoreNewlyCreated = await this.client.store.isNewlyCreated(); + if (!isStoreNewlyCreated) { + const prevClientOptions = await this.client.store.getClientOptions(); + if (prevClientOptions) { + lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers; + } + return lazyLoadMembersBefore !== lazyLoadMembers; + } + return false; + } + + private shouldAbortSync(error: MatrixError): boolean { + if (error.errcode === "M_UNKNOWN_TOKEN") { + // The logout already happened, we just need to stop. + logger.warn("Token no longer valid - assuming logout"); + this.stop(); + return true; + } + return false; + } + + /** + * Main entry point + */ + public sync(): void { + const client = this.client; + + this.running = true; + + if (global.window && global.window.addEventListener) { + global.window.addEventListener("online", this.onOnline, false); + } + + let savedSyncPromise = Promise.resolve(); + let savedSyncToken = null; + + // We need to do one-off checks before we can begin the /sync loop. + // These are: + // 1) We need to get push rules so we can check if events should bing as we get + // them from /sync. + // 2) We need to get/create a filter which we can use for /sync. + // 3) We need to check the lazy loading option matches what was used in the + // stored sync. If it doesn't, we can't use the stored sync. + + const getPushRules = async () => { + try { + debuglog("Getting push rules..."); + const result = await client.getPushRules(); + debuglog("Got push rules"); + + client.pushRules = result; + } catch (err) { + logger.error("Getting push rules failed", err); + if (this.shouldAbortSync(err)) return; + // wait for saved sync to complete before doing anything else, + // otherwise the sync state will end up being incorrect + debuglog("Waiting for saved sync before retrying push rules..."); + await this.recoverFromSyncStartupError(savedSyncPromise, err); + getPushRules(); + return; + } + checkLazyLoadStatus(); // advance to the next stage + }; + + const buildDefaultFilter = () => { + const filter = new Filter(client.credentials.userId); + filter.setTimelineLimit(this.opts.initialSyncLimit); + return filter; + }; + + const checkLazyLoadStatus = async () => { + debuglog("Checking lazy load status..."); + if (this.opts.lazyLoadMembers && client.isGuest()) { + this.opts.lazyLoadMembers = false; + } + if (this.opts.lazyLoadMembers) { + debuglog("Checking server lazy load support..."); + const supported = await client.doesServerSupportLazyLoading(); + if (supported) { + debuglog("Enabling lazy load on sync filter..."); + if (!this.opts.filter) { + this.opts.filter = buildDefaultFilter(); + } + this.opts.filter.setLazyLoadMembers(true); + } else { + debuglog("LL: lazy loading requested but not supported " + + "by server, so disabling"); + this.opts.lazyLoadMembers = false; + } + } + // need to vape the store when enabling LL and wasn't enabled before + debuglog("Checking whether lazy loading has changed in store..."); + const shouldClear = await this.wasLazyLoadingToggled(this.opts.lazyLoadMembers); + if (shouldClear) { + this.storeIsInvalid = true; + const reason = InvalidStoreError.TOGGLED_LAZY_LOADING; + const error = new InvalidStoreError(reason, !!this.opts.lazyLoadMembers); + this.updateSyncState(SyncState.Error, { error }); + // bail out of the sync loop now: the app needs to respond to this error. + // we leave the state as 'ERROR' which isn't great since this normally means + // we're retrying. The client must be stopped before clearing the stores anyway + // so the app should stop the client, clear the store and start it again. + logger.warn("InvalidStoreError: store is not usable: stopping sync."); + return; + } + if (this.opts.lazyLoadMembers && this.opts.crypto) { + this.opts.crypto.enableLazyLoading(); + } + try { + debuglog("Storing client options..."); + await this.client.storeClientOptions(); + debuglog("Stored client options"); + } catch (err) { + logger.error("Storing client options failed", err); + throw err; + } + + getFilter(); // Now get the filter and start syncing + }; + + const getFilter = async () => { + debuglog("Getting filter..."); + let filter; + if (this.opts.filter) { + filter = this.opts.filter; + } else { + filter = buildDefaultFilter(); + } + + let filterId; + try { + filterId = await client.getOrCreateFilter(getFilterName(client.credentials.userId), filter); + } catch (err) { + logger.error("Getting filter failed", err); + if (this.shouldAbortSync(err)) return; + // wait for saved sync to complete before doing anything else, + // otherwise the sync state will end up being incorrect + debuglog("Waiting for saved sync before retrying filter..."); + await this.recoverFromSyncStartupError(savedSyncPromise, err); + getFilter(); + return; + } + // reset the notifications timeline to prepare it to paginate from + // the current point in time. + // The right solution would be to tie /sync pagination tokens into + // /notifications API somehow. + client.resetNotifTimelineSet(); + + if (this.currentSyncRequest === null) { + // Send this first sync request here so we can then wait for the saved + // sync data to finish processing before we process the results of this one. + debuglog("Sending first sync request..."); + this.currentSyncRequest = this.doSyncRequest({ filterId }, savedSyncToken); + } + + // Now wait for the saved sync to finish... + debuglog("Waiting for saved sync before starting sync processing..."); + await savedSyncPromise; + this._sync({ filterId }); + }; + + if (client.isGuest()) { + // no push rules for guests, no access to POST filter for guests. + this._sync({}); + } else { + // Pull the saved sync token out first, before the worker starts sending + // all the sync data which could take a while. This will let us send our + // first incremental sync request before we've processed our saved data. + debuglog("Getting saved sync token..."); + savedSyncPromise = client.store.getSavedSyncToken().then((tok) => { + debuglog("Got saved sync token"); + savedSyncToken = tok; + debuglog("Getting saved sync..."); + return client.store.getSavedSync(); + }).then((savedSync) => { + debuglog(`Got reply from saved sync, exists? ${!!savedSync}`); + if (savedSync) { + return this.syncFromCache(savedSync); + } + }).catch(err => { + logger.error("Getting saved sync failed", err); + }); + // Now start the first incremental sync request: this can also + // take a while so if we set it going now, we can wait for it + // to finish while we process our saved sync data. + getPushRules(); + } + } + + /** + * Stops the sync object from syncing. + */ + public stop(): void { + debuglog("SyncApi.stop"); + if (global.window) { + global.window.removeEventListener("online", this.onOnline, false); + } + this.running = false; + if (this.currentSyncRequest) { + this.currentSyncRequest.abort(); + } + if (this.keepAliveTimer) { + clearTimeout(this.keepAliveTimer); + this.keepAliveTimer = null; + } + } + + /** + * Retry a backed off syncing request immediately. This should only be used when + * the user explicitly attempts to retry their lost connection. + * @return {boolean} True if this resulted in a request being retried. + */ + public retryImmediately(): boolean { + if (!this.connectionReturnedDefer) { + return false; + } + this.startKeepAlives(0); + return true; + } + /** + * Process a single set of cached sync data. + * @param {Object} savedSync a saved sync that was persisted by a store. This + * should have been acquired via client.store.getSavedSync(). + */ + private async syncFromCache(savedSync: ISavedSync): Promise { + debuglog("sync(): not doing HTTP hit, instead returning stored /sync data"); + + const nextSyncToken = savedSync.nextBatch; + + // Set sync token for future incremental syncing + this.client.store.setSyncToken(nextSyncToken); + + // No previous sync, set old token to null + const syncEventData = { + oldSyncToken: null, + nextSyncToken, + catchingUp: false, + fromCache: true, + }; + + const data: ISyncResponse = { + next_batch: nextSyncToken, + rooms: savedSync.roomsData, + groups: savedSync.groupsData, + account_data: { + events: savedSync.accountData, + }, + }; + + try { + await this.processSyncResponse(syncEventData, data); + } catch (e) { + logger.error("Error processing cached sync", e.stack || e); + } + + // Don't emit a prepared if we've bailed because the store is invalid: + // in this case the client will not be usable until stopped & restarted + // so this would be useless and misleading. + if (!this.storeIsInvalid) { + this.updateSyncState(SyncState.Prepared, syncEventData); + } + } + + /** + * Invoke me to do /sync calls + * @param {Object} syncOptions + * @param {string} syncOptions.filterId + * @param {boolean} syncOptions.hasSyncedBefore + */ + private async _sync(syncOptions: ISyncOptions): Promise { + const client = this.client; + + if (!this.running) { + debuglog("Sync no longer running: exiting."); + if (this.connectionReturnedDefer) { + this.connectionReturnedDefer.reject(); + this.connectionReturnedDefer = null; + } + this.updateSyncState(SyncState.Stopped); + return; + } + + const syncToken = client.store.getSyncToken(); + + let data; + try { + //debuglog('Starting sync since=' + syncToken); + if (this.currentSyncRequest === null) { + this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken); + } + data = await this.currentSyncRequest; + } catch (e) { + this.onSyncError(e, syncOptions); + return; + } finally { + this.currentSyncRequest = null; + } + + //debuglog('Completed sync, next_batch=' + data.next_batch); + + // set the sync token NOW *before* processing the events. We do this so + // if something barfs on an event we can skip it rather than constantly + // polling with the same token. + client.store.setSyncToken(data.next_batch); + + // Reset after a successful sync + this.failedSyncCount = 0; + + await client.store.setSyncData(data); + + const syncEventData = { + oldSyncToken: syncToken, + nextSyncToken: data.next_batch, + catchingUp: this.catchingUp, + }; + + if (this.opts.crypto) { + // tell the crypto module we're about to process a sync + // response + await this.opts.crypto.onSyncWillProcess(syncEventData); + } + + try { + await this.processSyncResponse(syncEventData, data); + } catch (e) { + // log the exception with stack if we have it, else fall back + // to the plain description + logger.error("Caught /sync error", e.stack || e); + + // Emit the exception for client handling + this.client.emit("sync.unexpectedError", e); + } + + // update this as it may have changed + syncEventData.catchingUp = this.catchingUp; + + // emit synced events + if (!syncOptions.hasSyncedBefore) { + this.updateSyncState(SyncState.Prepared, syncEventData); + syncOptions.hasSyncedBefore = true; + } + + // tell the crypto module to do its processing. It may block (to do a + // /keys/changes request). + if (this.opts.crypto) { + await this.opts.crypto.onSyncCompleted(syncEventData); + } + + // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates + this.updateSyncState(SyncState.Syncing, syncEventData); + + if (client.store.wantsSave()) { + // We always save the device list (if it's dirty) before saving the sync data: + // this means we know the saved device list data is at least as fresh as the + // stored sync data which means we don't have to worry that we may have missed + // device changes. We can also skip the delay since we're not calling this very + // frequently (and we don't really want to delay the sync for it). + if (this.opts.crypto) { + await this.opts.crypto.saveDeviceList(0); + } + + // tell databases that everything is now in a consistent state and can be saved. + client.store.save(); + } + + // Begin next sync + this._sync(syncOptions); + } + + private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): IRequestPromise { + const qps = this.getSyncParams(syncOptions, syncToken); + return this.client.http.authedRequest( + undefined, "GET", "/sync", qps, undefined, + qps.timeout + BUFFER_PERIOD_MS, + ); + } + + private getSyncParams(syncOptions: ISyncOptions, syncToken: string): ISyncParams { + let pollTimeout = this.opts.pollTimeout; + + if (this.getSyncState() !== 'SYNCING' || this.catchingUp) { + // unless we are happily syncing already, we want the server to return + // as quickly as possible, even if there are no events queued. This + // serves two purposes: + // + // * When the connection dies, we want to know asap when it comes back, + // so that we can hide the error from the user. (We don't want to + // have to wait for an event or a timeout). + // + // * We want to know if the server has any to_device messages queued up + // for us. We do that by calling it with a zero timeout until it + // doesn't give us any more to_device messages. + this.catchingUp = true; + pollTimeout = 0; + } + + let filterId = syncOptions.filterId; + if (this.client.isGuest() && !filterId) { + filterId = this.getGuestFilter(); + } + + const qps: ISyncParams = { + filter: filterId, + timeout: pollTimeout, + }; + + if (this.opts.disablePresence) { + qps.set_presence = "offline"; + } + + if (syncToken) { + qps.since = syncToken; + } else { + // use a cachebuster for initialsyncs, to make sure that + // we don't get a stale sync + // (https://github.com/vector-im/vector-web/issues/1354) + qps._cacheBuster = Date.now(); + } + + if (this.getSyncState() == 'ERROR' || this.getSyncState() == 'RECONNECTING') { + // we think the connection is dead. If it comes back up, we won't know + // about it till /sync returns. If the timeout= is high, this could + // be a long time. Set it to 0 when doing retries so we don't have to wait + // for an event or a timeout before emiting the SYNCING event. + qps.timeout = 0; + } + + return qps; + } + + private onSyncError(err: Error, syncOptions: ISyncOptions): void { + if (!this.running) { + debuglog("Sync no longer running: exiting"); + if (this.connectionReturnedDefer) { + this.connectionReturnedDefer.reject(); + this.connectionReturnedDefer = null; + } + this.updateSyncState(SyncState.Stopped); + return; + } + + logger.error("/sync error %s", err); + logger.error(err); + + if (this.shouldAbortSync(err)) { + return; + } + + this.failedSyncCount++; + logger.log('Number of consecutive failed sync requests:', this.failedSyncCount); + + debuglog("Starting keep-alive"); + // Note that we do *not* mark the sync connection as + // lost yet: we only do this if a keepalive poke + // fails, since long lived HTTP connections will + // go away sometimes and we shouldn't treat this as + // erroneous. We set the state to 'reconnecting' + // instead, so that clients can observe this state + // if they wish. + this.startKeepAlives().then((connDidFail) => { + // Only emit CATCHUP if we detected a connectivity error: if we didn't, + // it's quite likely the sync will fail again for the same reason and we + // want to stay in ERROR rather than keep flip-flopping between ERROR + // and CATCHUP. + if (connDidFail && this.getSyncState() === SyncState.Error) { + this.updateSyncState(SyncState.Catchup, { + oldSyncToken: null, + nextSyncToken: null, + catchingUp: true, + }); + } + this._sync(syncOptions); + }); + + this.currentSyncRequest = null; + // Transition from RECONNECTING to ERROR after a given number of failed syncs + this.updateSyncState( + this.failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? + SyncState.Error : SyncState.Reconnecting, + { error: err }, + ); + } + + /** + * Process data returned from a sync response and propagate it + * into the model objects + * + * @param {Object} syncEventData Object containing sync tokens associated with this sync + * @param {Object} data The response from /sync + */ + private async processSyncResponse(syncEventData: ISyncStateData, data: ISyncResponse): Promise { + const client = this.client; + + // data looks like: + // { + // next_batch: $token, + // presence: { events: [] }, + // account_data: { events: [] }, + // device_lists: { changed: ["@user:server", ... ]}, + // to_device: { events: [] }, + // device_one_time_keys_count: { signed_curve25519: 42 }, + // rooms: { + // invite: { + // $roomid: { + // invite_state: { events: [] } + // } + // }, + // join: { + // $roomid: { + // state: { events: [] }, + // timeline: { events: [], prev_batch: $token, limited: true }, + // ephemeral: { events: [] }, + // summary: { + // m.heroes: [ $user_id ], + // m.joined_member_count: $count, + // m.invited_member_count: $count + // }, + // account_data: { events: [] }, + // unread_notifications: { + // highlight_count: 0, + // notification_count: 0, + // } + // } + // }, + // leave: { + // $roomid: { + // state: { events: [] }, + // timeline: { events: [], prev_batch: $token } + // } + // } + // }, + // groups: { + // invite: { + // $groupId: { + // inviter: $inviter, + // profile: { + // avatar_url: $avatarUrl, + // name: $groupName, + // }, + // }, + // }, + // join: {}, + // leave: {}, + // }, + // } + + // TODO-arch: + // - Each event we pass through needs to be emitted via 'event', can we + // do this in one place? + // - The isBrandNewRoom boilerplate is boilerplatey. + + // handle presence events (User objects) + if (data.presence && Array.isArray(data.presence.events)) { + data.presence.events.map(client.getEventMapper()).forEach( + function(presenceEvent) { + let user = client.store.getUser(presenceEvent.getSender()); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(client, presenceEvent.getSender()); + user.setPresenceEvent(presenceEvent); + client.store.storeUser(user); + } + client.emit("event", presenceEvent); + }); + } + + // handle non-room account_data + if (data.account_data && Array.isArray(data.account_data.events)) { + const events = data.account_data.events.map(client.getEventMapper()); + const prevEventsMap = events.reduce((m, c) => { + m[c.getId()] = client.store.getAccountData(c.getType()); + return m; + }, {}); + client.store.storeAccountDataEvents(events); + events.forEach( + function(accountDataEvent) { + // Honour push rules that come down the sync stream but also + // honour push rules that were previously cached. Base rules + // will be updated when we receive push rules via getPushRules + // (see sync) before syncing over the network. + if (accountDataEvent.getType() === 'm.push_rules') { + const rules = accountDataEvent.getContent(); + client.pushRules = PushProcessor.rewriteDefaultRules(rules); + } + const prevEvent = prevEventsMap[accountDataEvent.getId()]; + client.emit("accountData", accountDataEvent, prevEvent); + return accountDataEvent; + }, + ); + } + + // handle to-device events + if (data.to_device && Array.isArray(data.to_device.events) && + data.to_device.events.length > 0 + ) { + const cancelledKeyVerificationTxns = []; + data.to_device.events + .map(client.getEventMapper()) + .map((toDeviceEvent) => { // map is a cheap inline forEach + // We want to flag m.key.verification.start events as cancelled + // if there's an accompanying m.key.verification.cancel event, so + // we pull out the transaction IDs from the cancellation events + // so we can flag the verification events as cancelled in the loop + // below. + if (toDeviceEvent.getType() === "m.key.verification.cancel") { + const txnId = toDeviceEvent.getContent()['transaction_id']; + if (txnId) { + cancelledKeyVerificationTxns.push(txnId); + } + } + + // as mentioned above, .map is a cheap inline forEach, so return + // the unmodified event. + return toDeviceEvent; + }) + .forEach( + function(toDeviceEvent) { + const content = toDeviceEvent.getContent(); + if ( + toDeviceEvent.getType() == "m.room.message" && + content.msgtype == "m.bad.encrypted" + ) { + // the mapper already logged a warning. + logger.log( + 'Ignoring undecryptable to-device event from ' + + toDeviceEvent.getSender(), + ); + return; + } + + if (toDeviceEvent.getType() === "m.key.verification.start" + || toDeviceEvent.getType() === "m.key.verification.request") { + const txnId = content['transaction_id']; + if (cancelledKeyVerificationTxns.includes(txnId)) { + toDeviceEvent.flagCancelled(); + } + } + + client.emit("toDeviceEvent", toDeviceEvent); + }, + ); + } else { + // no more to-device events: we can stop polling with a short timeout. + this.catchingUp = false; + } + + if (data.groups) { + if (data.groups.invite) { + this.processGroupSyncEntry(data.groups.invite, Category.Invite); + } + + if (data.groups.join) { + this.processGroupSyncEntry(data.groups.join, Category.Join); + } + + if (data.groups.leave) { + this.processGroupSyncEntry(data.groups.leave, Category.Leave); + } + } + + // the returned json structure is a bit crap, so make it into a + // nicer form (array) after applying sanity to make sure we don't fail + // on missing keys (on the off chance) + let inviteRooms: WrappedRoom[] = []; + let joinRooms: WrappedRoom[] = []; + let leaveRooms: WrappedRoom[] = []; + + if (data.rooms) { + if (data.rooms.invite) { + inviteRooms = this.mapSyncResponseToRoomArray(data.rooms.invite); + } + if (data.rooms.join) { + joinRooms = this.mapSyncResponseToRoomArray(data.rooms.join); + } + if (data.rooms.leave) { + leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); + } + } + + this.notifEvents = []; + + // Handle invites + inviteRooms.forEach((inviteObj) => { + const room = inviteObj.room; + const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room); + + this.processRoomEvents(room, stateEvents); + if (inviteObj.isBrandNewRoom) { + room.recalculate(); + client.store.storeRoom(room); + client.emit("Room", room); + } + stateEvents.forEach(function(e) { + client.emit("event", e); + }); + room.updateMyMembership("invite"); + }); + + // Handle joins + await utils.promiseMapSeries(joinRooms, async (joinObj) => { + const room = joinObj.room; + const stateEvents = this.mapSyncEventsFormat(joinObj.state, room); + // Prevent events from being decrypted ahead of time + // this helps large account to speed up faster + // room::decryptCriticalEvent is in charge of decrypting all the events + // required for a client to function properly + const timelineEvents = this.mapSyncEventsFormat(joinObj.timeline, room, false); + const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral); + const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data); + + const encrypted = client.isRoomEncrypted(room.roomId); + // we do this first so it's correct when any of the events fire + if (joinObj.unread_notifications) { + room.setUnreadNotificationCount( + NotificationCountType.Total, + joinObj.unread_notifications.notification_count, + ); + + // We track unread notifications ourselves in encrypted rooms, so don't + // bother setting it here. We trust our calculations better than the + // server's for this case, and therefore will assume that our non-zero + // count is accurate. + if (!encrypted + || (encrypted && room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0)) { + room.setUnreadNotificationCount( + NotificationCountType.Highlight, + joinObj.unread_notifications.highlight_count, + ); + } + } + + joinObj.timeline = joinObj.timeline || {} as ITimeline; + + if (joinObj.isBrandNewRoom) { + // set the back-pagination token. Do this *before* adding any + // events so that clients can start back-paginating. + room.getLiveTimeline().setPaginationToken( + joinObj.timeline.prev_batch, EventTimeline.BACKWARDS); + } else if (joinObj.timeline.limited) { + let limited = true; + + // we've got a limited sync, so we *probably* have a gap in the + // timeline, so should reset. But we might have been peeking or + // paginating and already have some of the events, in which + // case we just want to append any subsequent events to the end + // of the existing timeline. + // + // This is particularly important in the case that we already have + // *all* of the events in the timeline - in that case, if we reset + // the timeline, we'll end up with an entirely empty timeline, + // which we'll try to paginate but not get any new events (which + // will stop us linking the empty timeline into the chain). + // + for (let i = timelineEvents.length - 1; i >= 0; i--) { + const eventId = timelineEvents[i].getId(); + if (room.getTimelineForEvent(eventId)) { + debuglog("Already have event " + eventId + " in limited " + + "sync - not resetting"); + limited = false; + + // we might still be missing some of the events before i; + // we don't want to be adding them to the end of the + // timeline because that would put them out of order. + timelineEvents.splice(0, i); + + // XXX: there's a problem here if the skipped part of the + // timeline modifies the state set in stateEvents, because + // we'll end up using the state from stateEvents rather + // than the later state from timelineEvents. We probably + // need to wind stateEvents forward over the events we're + // skipping. + + break; + } + } + + if (limited) { + this.deregisterStateListeners(room); + room.resetLiveTimeline( + joinObj.timeline.prev_batch, + this.opts.canResetEntireTimeline(room.roomId) ? + null : syncEventData.oldSyncToken, + ); + + // We have to assume any gap in any timeline is + // reason to stop incrementally tracking notifications and + // reset the timeline. + client.resetNotifTimelineSet(); + + this.registerStateListeners(room); + } + } + + this.processRoomEvents(room, stateEvents, timelineEvents, syncEventData.fromCache); + + // set summary after processing events, + // because it will trigger a name calculation + // which needs the room state to be up to date + if (joinObj.summary) { + room.setSummary(joinObj.summary); + } + + // we deliberately don't add ephemeral events to the timeline + room.addEphemeralEvents(ephemeralEvents); + + // we deliberately don't add accountData to the timeline + room.addAccountData(accountDataEvents); + + room.recalculate(); + if (joinObj.isBrandNewRoom) { + client.store.storeRoom(room); + client.emit("Room", room); + } + + this.processEventsForNotifs(room, timelineEvents); + + const processRoomEvent = async (e) => { + client.emit("event", e); + if (e.isState() && e.getType() == "m.room.encryption" && this.opts.crypto) { + await this.opts.crypto.onCryptoEvent(e); + } + if (e.isState() && e.getType() === "im.vector.user_status") { + let user = client.store.getUser(e.getStateKey()); + if (user) { + user.unstable_updateStatusMessage(e); + } else { + user = createNewUser(client, e.getStateKey()); + user.unstable_updateStatusMessage(e); + client.store.storeUser(user); + } + } + }; + + await utils.promiseMapSeries(stateEvents, processRoomEvent); + await utils.promiseMapSeries(timelineEvents, processRoomEvent); + ephemeralEvents.forEach(function(e) { + client.emit("event", e); + }); + accountDataEvents.forEach(function(e) { + client.emit("event", e); + }); + + room.updateMyMembership("join"); + + // Decrypt only the last message in all rooms to make sure we can generate a preview + // And decrypt all events after the recorded read receipt to ensure an accurate + // notification count + room.decryptCriticalEvents(); + }); + + // Handle leaves (e.g. kicked rooms) + leaveRooms.forEach((leaveObj) => { + const room = leaveObj.room; + const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); + const timelineEvents = this.mapSyncEventsFormat(leaveObj.timeline, room); + const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data); + + this.processRoomEvents(room, stateEvents, timelineEvents); + room.addAccountData(accountDataEvents); + + room.recalculate(); + if (leaveObj.isBrandNewRoom) { + client.store.storeRoom(room); + client.emit("Room", room); + } + + this.processEventsForNotifs(room, timelineEvents); + + stateEvents.forEach(function(e) { + client.emit("event", e); + }); + timelineEvents.forEach(function(e) { + client.emit("event", e); + }); + accountDataEvents.forEach(function(e) { + client.emit("event", e); + }); + + room.updateMyMembership("leave"); + }); + + // update the notification timeline, if appropriate. + // we only do this for live events, as otherwise we can't order them sanely + // in the timeline relative to ones paginated in by /notifications. + // XXX: we could fix this by making EventTimeline support chronological + // ordering... but it doesn't, right now. + if (syncEventData.oldSyncToken && this.notifEvents.length) { + this.notifEvents.sort(function(a, b) { + return a.getTs() - b.getTs(); + }); + this.notifEvents.forEach(function(event) { + client.getNotifTimelineSet().addLiveEvent(event); + }); + } + + // Handle device list updates + if (data.device_lists) { + if (this.opts.crypto) { + await this.opts.crypto.handleDeviceListChanges(syncEventData, data.device_lists); + } else { + // FIXME if we *don't* have a crypto module, we still need to + // invalidate the device lists. But that would require a + // substantial bit of rework :/. + } + } + + // Handle one_time_keys_count + if (this.opts.crypto && data.device_one_time_keys_count) { + const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0; + this.opts.crypto.updateOneTimeKeyCount(currentCount); + } + if (this.opts.crypto && data["org.matrix.msc2732.device_unused_fallback_key_types"]) { + // The presence of device_unused_fallback_key_types indicates that the + // server supports fallback keys. If there's no unused + // signed_curve25519 fallback key we need a new one. + const unusedFallbackKeys = data["org.matrix.msc2732.device_unused_fallback_key_types"]; + this.opts.crypto.setNeedsNewFallback( + unusedFallbackKeys instanceof Array && + !unusedFallbackKeys.includes("signed_curve25519"), + ); + } + } + + /** + * Starts polling the connectivity check endpoint + * @param {number} delay How long to delay until the first poll. + * defaults to a short, randomised interval (to prevent + * tightlooping if /versions succeeds but /sync etc. fail). + * @return {promise} which resolves once the connection returns + */ + private startKeepAlives(delay?: number): Promise { + if (delay === undefined) { + delay = 2000 + Math.floor(Math.random() * 5000); + } + + if (this.keepAliveTimer !== null) { + clearTimeout(this.keepAliveTimer); + } + if (delay > 0) { + this.keepAliveTimer = setTimeout(this.pokeKeepAlive.bind(this), delay); + } else { + this.pokeKeepAlive(); + } + if (!this.connectionReturnedDefer) { + this.connectionReturnedDefer = utils.defer(); + } + return this.connectionReturnedDefer.promise; + } + + /** + * Make a dummy call to /_matrix/client/versions, to see if the HS is + * reachable. + * + * On failure, schedules a call back to itself. On success, resolves + * this.connectionReturnedDefer. + * + * @param {boolean} connDidFail True if a connectivity failure has been detected. Optional. + */ + private pokeKeepAlive(connDidFail = false): void { + const success = () => { + clearTimeout(this.keepAliveTimer); + if (this.connectionReturnedDefer) { + this.connectionReturnedDefer.resolve(connDidFail); + this.connectionReturnedDefer = null; + } + }; + + this.client.http.request( + undefined, // callback + "GET", "/_matrix/client/versions", + undefined, // queryParams + undefined, // data + { + prefix: '', + localTimeoutMs: 15 * 1000, + }, + ).then(() => { + success(); + }, (err) => { + if (err.httpStatus == 400 || err.httpStatus == 404) { + // treat this as a success because the server probably just doesn't + // support /versions: point is, we're getting a response. + // We wait a short time though, just in case somehow the server + // is in a mode where it 400s /versions responses and sync etc. + // responses fail, this will mean we don't hammer in a loop. + this.keepAliveTimer = setTimeout(success, 2000); + } else { + connDidFail = true; + this.keepAliveTimer = setTimeout( + this.pokeKeepAlive.bind(this, connDidFail), + 5000 + Math.floor(Math.random() * 5000), + ); + // A keepalive has failed, so we emit the + // error state (whether or not this is the + // first failure). + // Note we do this after setting the timer: + // this lets the unit tests advance the mock + // clock when they get the error. + this.updateSyncState(SyncState.Error, { error: err }); + } + }); + } + + /** + * @param {Object} groupsSection Groups section object, eg. response.groups.invite + * @param {string} sectionName Which section this is ('invite', 'join' or 'leave') + */ + private processGroupSyncEntry(groupsSection: object, sectionName: Category) { + // Processes entries from 'groups' section of the sync stream + for (const groupId of Object.keys(groupsSection)) { + const groupInfo = groupsSection[groupId]; + let group = this.client.store.getGroup(groupId); + const isBrandNew = group === null; + if (group === null) { + group = this.createGroup(groupId); + } + if (groupInfo.profile) { + group.setProfile( + groupInfo.profile.name, groupInfo.profile.avatar_url, + ); + } + if (groupInfo.inviter) { + group.setInviter({ userId: groupInfo.inviter }); + } + group.setMyMembership(sectionName); + if (isBrandNew) { + // Now we've filled in all the fields, emit the Group event + this.client.emit("Group", group); + } + } + } + + /** + * @param {Object} obj + * @return {Object[]} + */ + private mapSyncResponseToRoomArray( + obj: Record, + ): Array> { + // Maps { roomid: {stuff}, roomid: {stuff} } + // to + // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}] + const client = this.client; + return Object.keys(obj).map((roomId) => { + const arrObj = obj[roomId] as T & { room: Room, isBrandNewRoom: boolean }; + let room = client.store.getRoom(roomId); + let isBrandNewRoom = false; + if (!room) { + room = this.createRoom(roomId); + isBrandNewRoom = true; + } + arrObj.room = room; + arrObj.isBrandNewRoom = isBrandNewRoom; + return arrObj; + }); + } + + /** + * @param {Object} obj + * @param {Room} room + * @param {boolean} decrypt + * @return {MatrixEvent[]} + */ + private mapSyncEventsFormat( + obj: IInviteState | ITimeline | IEphemeral, + room?: Room, + decrypt = true, + ): MatrixEvent[] { + if (!obj || !Array.isArray(obj.events)) { + return []; + } + const mapper = this.client.getEventMapper({ decrypt }); + return (obj.events as Array).map(function(e) { + if (room) { + e["room_id"] = room.roomId; + } + return mapper(e); + }); + } + + /** + * @param {Room} room + */ + private resolveInvites(room: Room): void { + if (!room || !this.opts.resolveInvitesToProfiles) { + return; + } + const client = this.client; + // For each invited room member we want to give them a displayname/avatar url + // if they have one (the m.room.member invites don't contain this). + room.getMembersWithMembership("invite").forEach(function(member) { + if (member._requestedProfileInfo) return; + member._requestedProfileInfo = true; + // try to get a cached copy first. + const user = client.getUser(member.userId); + let promise; + if (user) { + promise = Promise.resolve({ + avatar_url: user.avatarUrl, + displayname: user.displayName, + }); + } else { + promise = client.getProfileInfo(member.userId); + } + promise.then(function(info) { + // slightly naughty by doctoring the invite event but this means all + // the code paths remain the same between invite/join display name stuff + // which is a worthy trade-off for some minor pollution. + const inviteEvent = member.events.member; + if (inviteEvent.getContent().membership !== "invite") { + // between resolving and now they have since joined, so don't clobber + return; + } + inviteEvent.getContent().avatar_url = info.avatar_url; + inviteEvent.getContent().displayname = info.displayname; + // fire listeners + member.setMembershipEvent(inviteEvent, room.currentState); + }, function(err) { + // OH WELL. + }); + }); + } + + /** + * @param {Room} room + * @param {MatrixEvent[]} stateEventList A list of state events. This is the state + * at the *START* of the timeline list if it is supplied. + * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index + * @param {boolean} fromCache whether the sync response came from cache + * is earlier in time. Higher index is later. + */ + private processRoomEvents( + room: Room, + stateEventList: MatrixEvent[], + timelineEventList?: MatrixEvent[], + fromCache = false, + ): void { + // If there are no events in the timeline yet, initialise it with + // the given state events + const liveTimeline = room.getLiveTimeline(); + const timelineWasEmpty = liveTimeline.getEvents().length == 0; + if (timelineWasEmpty) { + // Passing these events into initialiseState will freeze them, so we need + // to compute and cache the push actions for them now, otherwise sync dies + // with an attempt to assign to read only property. + // XXX: This is pretty horrible and is assuming all sorts of behaviour from + // these functions that it shouldn't be. We should probably either store the + // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise + // find some solution where MatrixEvents are immutable but allow for a cache + // field. + for (const ev of stateEventList) { + this.client.getPushActionsForEvent(ev); + } + liveTimeline.initialiseState(stateEventList); + } + + this.resolveInvites(room); + + // recalculate the room name at this point as adding events to the timeline + // may make notifications appear which should have the right name. + // XXX: This looks suspect: we'll end up recalculating the room once here + // and then again after adding events (processSyncResponse calls it after + // calling us) even if no state events were added. It also means that if + // one of the room events in timelineEventList is something that needs + // a recalculation (like m.room.name) we won't recalculate until we've + // finished adding all the events, which will cause the notification to have + // the old room name rather than the new one. + room.recalculate(); + + // If the timeline wasn't empty, we process the state events here: they're + // defined as updates to the state before the start of the timeline, so this + // starts to roll the state forward. + // XXX: That's what we *should* do, but this can happen if we were previously + // peeking in a room, in which case we obviously do *not* want to add the + // state events here onto the end of the timeline. Historically, the js-sdk + // has just set these new state events on the old and new state. This seems + // very wrong because there could be events in the timeline that diverge the + // state, in which case this is going to leave things out of sync. However, + // for now I think it;s best to behave the same as the code has done previously. + if (!timelineWasEmpty) { + // XXX: As above, don't do this... + //room.addLiveEvents(stateEventList || []); + // Do this instead... + room.oldState.setStateEvents(stateEventList || []); + room.currentState.setStateEvents(stateEventList || []); + } + // execute the timeline events. This will continue to diverge the current state + // if the timeline has any state events in it. + // This also needs to be done before running push rules on the events as they need + // to be decorated with sender etc. + room.addLiveEvents(timelineEventList || [], null, fromCache); + } + + /** + * Takes a list of timelineEvents and adds and adds to notifEvents + * as appropriate. + * This must be called after the room the events belong to has been stored. + * + * @param {Room} room + * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index + * is earlier in time. Higher index is later. + */ + private processEventsForNotifs(room: Room, timelineEventList: MatrixEvent[]): void { + // gather our notifications into this.notifEvents + if (this.client.getNotifTimelineSet()) { + for (let i = 0; i < timelineEventList.length; i++) { + const pushActions = this.client.getPushActionsForEvent(timelineEventList[i]); + if (pushActions && pushActions.notify && + pushActions.tweaks && pushActions.tweaks.highlight) { + this.notifEvents.push(timelineEventList[i]); + } + } + } + } + + /** + * @return {string} + */ + private getGuestFilter(): string { + // 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 "{}"; + } + + /** + * Sets the sync state and emits an event to say so + * @param {String} newState The new state string + * @param {Object} data Object of additional data to emit in the event + */ + private updateSyncState(newState: SyncState, data?: ISyncStateData): void { + const old = this.syncState; + this.syncState = newState; + this.syncStateData = data; + this.client.emit("sync", this.syncState, old, data); + } + + /** + * Event handler for the 'online' event + * This event is generally unreliable and precise behaviour + * varies between browsers, so we poll for connectivity too, + * but this might help us reconnect a little faster. + */ + private onOnline = (): void => { + debuglog("Browser thinks we are back online"); + this.startKeepAlives(0); + }; +} + +function createNewUser(client: MatrixClient, userId: string): User { + const user = new User(userId); + client.reEmitter.reEmit(user, [ + "User.avatarUrl", "User.displayName", "User.presence", + "User.currentlyActive", "User.lastPresenceTs", + ]); + return user; +} + diff --git a/src/utils.ts b/src/utils.ts index a46f0bdcd..008388509 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -438,7 +438,7 @@ export function isNullOrUndefined(val: any): boolean { export interface IDeferred { resolve: (value: T) => void; - reject: (any) => void; + reject: (reason?: any) => void; promise: Promise; } @@ -456,10 +456,10 @@ export function defer(): IDeferred { } export async function promiseMapSeries( - promises: Promise[], + promises: T[], fn: (t: T) => void, ): Promise { - for (const o of await promises) { + for (const o of promises) { await fn(await o); } } From b2ad957d298720d3e026b6bd91be0c403338361a Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 1 Jul 2021 23:28:18 +0100 Subject: [PATCH 115/124] Comply with new member-delimiter-style rule Just `eslint --fix` with rule from https://github.com/matrix-org/eslint-plugin-matrix-org/pull/9 in place --- src/client.ts | 2 +- src/crypto/backup.ts | 14 +++++++------- src/crypto/index.ts | 4 ++-- src/crypto/key_passphrase.ts | 2 +- src/models/event.ts | 6 +++--- src/webrtc/call.ts | 20 ++++++++++---------- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/client.ts b/src/client.ts index a0b8cb9e2..9070c8505 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7637,7 +7637,7 @@ export class MatrixClient extends EventEmitter { */ public sendToDevice( eventType: string, - contentMap: { [userId: string]: { [deviceId: string]: Record; } }, + contentMap: { [userId: string]: { [deviceId: string]: Record } }, txnId?: string, ): Promise<{}> { const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", { diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 3a8422a74..533391831 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -36,16 +36,16 @@ const KEY_BACKUP_KEYS_PER_REQUEST = 200; type AuthData = IKeyBackupInfo["auth_data"]; type SigInfo = { - deviceId: string, - valid?: boolean | null, // true: valid, false: invalid, null: cannot attempt validation - device?: DeviceInfo | null, - crossSigningId?: boolean, - deviceTrust?: DeviceTrustLevel, + deviceId: string; + valid?: boolean | null; // true: valid, false: invalid, null: cannot attempt validation + device?: DeviceInfo | null; + crossSigningId?: boolean; + deviceTrust?: DeviceTrustLevel; }; export type TrustInfo = { - usable: boolean, // is the backup trusted, true iff there is a sig that is valid & from a trusted device - sigs: SigInfo[], + usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device + sigs: SigInfo[]; }; export interface IKeyBackupCheck { diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 070f8f5d9..140e32bd9 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -114,7 +114,7 @@ interface IRoomKey { export interface IRoomKeyRequestBody extends IRoomKey { session_id: string; - sender_key: string + sender_key: string; } export interface IMegolmSessionData { @@ -166,7 +166,7 @@ export interface IRoomKeyRequestRecipient { interface ISignableObject { signatures?: object; - unsigned?: object + unsigned?: object; } export interface IEventDecryptionResult { diff --git a/src/crypto/key_passphrase.ts b/src/crypto/key_passphrase.ts index 3631fe30f..ca11e7d2f 100644 --- a/src/crypto/key_passphrase.ts +++ b/src/crypto/key_passphrase.ts @@ -31,7 +31,7 @@ interface IAuthData { interface IKey { key: Uint8Array; salt: string; - iterations: number + iterations: number; } export async function keyFromAuthData(authData: IAuthData, password: string): Promise { diff --git a/src/models/event.ts b/src/models/event.ts index 490988787..755438548 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -120,9 +120,9 @@ interface IEventRelation { interface IDecryptionResult { clearEvent: { room_id?: string; - type: string, - content: IContent, - unsigned?: IUnsigned, + type: string; + content: IContent; + unsigned?: IUnsigned; }; forwardingCurve25519KeyChain?: string[]; senderCurve25519Key?: string; diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 18a9542a3..ed1af7b90 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -56,22 +56,22 @@ import { CallFeed } from './callFeed'; */ interface CallOpts { - roomId?: string, - client?: any, // Fix when client is TSified - forceTURN?: boolean, - turnServers?: Array, + roomId?: string; + client?: any; // Fix when client is TSified + forceTURN?: boolean; + turnServers?: Array; } interface TurnServer { - urls: Array, - username?: string, - password?: string, - ttl?: number, + urls: Array; + username?: string; + password?: string; + ttl?: number; } interface AssertedIdentity { - id: string, - displayName: string, + id: string; + displayName: string; } export enum CallState { From e0012d9b81983d5439933d97d6553948f748bafa Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 1 Jul 2021 23:46:13 +0100 Subject: [PATCH 116/124] Bump eslint plugin version Also yarn has decided that other line is going away too --- yarn.lock | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 25f6fa7df..076bd2252 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1132,7 +1132,6 @@ "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz": version "3.2.3" - uid cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4 resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents": @@ -2859,7 +2858,7 @@ eslint-config-google@^0.14.0: "eslint-plugin-matrix-org@github:matrix-org/eslint-plugin-matrix-org#main": version "0.3.2" - resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/28d392822533a7468be0dd806d0a4ba573a45d74" + resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/e8197938dca66849ffdac4baca7c05275df12835" eslint-rule-composer@^0.3.0: version "0.3.0" From f027cbf170de48e502fa09040fbba2262b8e8e26 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 1 Jul 2021 23:46:13 +0100 Subject: [PATCH 117/124] Bump eslint plugin version Also yarn has decided that other line is going away too --- yarn.lock | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 25f6fa7df..076bd2252 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1132,7 +1132,6 @@ "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz": version "3.2.3" - uid cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4 resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents": @@ -2859,7 +2858,7 @@ eslint-config-google@^0.14.0: "eslint-plugin-matrix-org@github:matrix-org/eslint-plugin-matrix-org#main": version "0.3.2" - resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/28d392822533a7468be0dd806d0a4ba573a45d74" + resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/e8197938dca66849ffdac4baca7c05275df12835" eslint-rule-composer@^0.3.0: version "0.3.0" From 6699c4d8af9d3aa9aa087cff5447ccd918f5acbd Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 1 Jul 2021 23:49:13 +0100 Subject: [PATCH 118/124] Revert e0012d9b81983d5439933d97d6553948f748bafa Accidentally comitted to develop --- yarn.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 076bd2252..25f6fa7df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1132,6 +1132,7 @@ "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz": version "3.2.3" + uid cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4 resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents": @@ -2858,7 +2859,7 @@ eslint-config-google@^0.14.0: "eslint-plugin-matrix-org@github:matrix-org/eslint-plugin-matrix-org#main": version "0.3.2" - resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/e8197938dca66849ffdac4baca7c05275df12835" + resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/28d392822533a7468be0dd806d0a4ba573a45d74" eslint-rule-composer@^0.3.0: version "0.3.0" From 521fce59ea9462019b1a1907c79dc4a5658f4735 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 2 Jul 2021 14:46:30 +0100 Subject: [PATCH 119/124] Tidy up secret requesting code Use a plain async function rather than a promise, so we don't have to squelch the lint warning. --- src/crypto/CrossSigning.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index 0d4823051..aacb47148 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -783,8 +783,7 @@ export async function requestKeysDuringVerification(baseApis: MatrixClient, user }); // also request and cache the key backup key - // eslint-disable-next-line no-async-promise-executor - const backupKeyPromise = new Promise(async resolve => { + const backupKeyPromise = (async () => { const cachedKey = await client.crypto.getSessionBackupPrivateKey(); if (!cachedKey) { logger.info("No cached backup key found. Requesting..."); @@ -805,8 +804,7 @@ export async function requestKeysDuringVerification(baseApis: MatrixClient, user logger.info("Backup restored."); }); } - resolve(); - }); + })(); // We call getCrossSigningKey() for its side-effects return Promise.race([ From 17be68a39d308f26da034e7212649a658a862365 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 5 Jul 2021 14:54:21 +0100 Subject: [PATCH 120/124] Prepare changelog for v12.0.1 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdef97e14..a7baac3a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +Changes in [12.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.1) (2021-07-05) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v12.0.1-rc.1...v12.0.1) + + * No changes from rc.1 + Changes in [12.0.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.1-rc.1) (2021-06-29) ============================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v12.0.0...v12.0.1-rc.1) From 834ab22923063243c677fff49f2734a9f44fcca1 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 5 Jul 2021 14:54:21 +0100 Subject: [PATCH 121/124] v12.0.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c03fc955..2697eb282 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "12.0.1-rc.1", + "version": "12.0.1", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", From dc90115a1bbd733351097fc97d93f47b0e31fa8d Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Mon, 5 Jul 2021 14:57:15 +0100 Subject: [PATCH 122/124] Resetting package fields for development --- package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 0ccbab649..b1c92efe6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "keywords": [ "matrix-org" ], - "main": "./lib/index.js", + "main": "./src/index.ts", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", @@ -110,6 +110,5 @@ "coverageReporters": [ "text" ] - }, - "typings": "./lib/index.d.ts" + } } From f0ae9b01000b870fcce269ca645cfcbe4d24f1dd Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Wed, 7 Jul 2021 16:11:14 +0200 Subject: [PATCH 123/124] Do not generate a lockfile when running in CI --- release.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release.sh b/release.sh index 7ee2777da..e2ef78263 100755 --- a/release.sh +++ b/release.sh @@ -100,7 +100,7 @@ fi # global cache here to ensure we get the right thing. yarn cache clean # Ensure all dependencies are updated -yarn install --ignore-scripts +yarn install --ignore-scripts --pure-lockfile if [ -z "$skip_changelog" ]; then # update_changelog doesn't have a --version flag @@ -225,7 +225,7 @@ if [ $dodist -eq 0 ]; then pushd "$builddir" git clone "$projdir" . git checkout "$rel_branch" - yarn install + yarn install --pure-lockfile # We haven't tagged yet, so tell the dist script what version # it's building DIST_VERSION="$tag" yarn dist From 79f74fdff4d1f1461d5420af4d813a01972b67c9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 7 Jul 2021 17:54:46 +0100 Subject: [PATCH 124/124] Improve return type for getUrlPreview --- src/client.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index a94ff04b3..da2d24547 100644 --- a/src/client.ts +++ b/src/client.ts @@ -414,6 +414,19 @@ interface IUploadKeySignaturesResponse { }>>; } +export interface IPreviewUrlResponse { + [key: string]: string | number; + "og:title": string; + "og:type": string; + "og:url": string; + "og:image"?: string; + "og:image:type"?: string; + "og:image:height"?: number; + "og:image:width"?: number; + "og:description"?: string; + "matrix:image:size"?: number; +} + /** * Represents a Matrix Client. Only directly construct this if you want to use * custom modules. Normally, {@link createClient} should be used @@ -3695,7 +3708,7 @@ export class MatrixClient extends EventEmitter { * @return {module:http-api.MatrixError} Rejects: with an error response. * May return synthesized attributes if the URL lacked OG meta. */ - public getUrlPreview(url: string, ts: number, callback?: Callback): Promise { + public getUrlPreview(url: string, ts: number, callback?: Callback): Promise { // bucket the timestamp to the nearest minute to prevent excessive spam to the server // Surely 60-second accuracy is enough for anyone. ts = Math.floor(ts / 60000) * 60000;