diff --git a/matrix-js-sdk/.babelrc b/matrix-js-sdk/.babelrc new file mode 100644 index 000000000..572b4baff --- /dev/null +++ b/matrix-js-sdk/.babelrc @@ -0,0 +1,15 @@ +{ + "presets": ["es2015"], + "plugins": [ + "transform-class-properties", + + // this transforms async functions into generator functions, which + // are then made to use the regenerator module by babel's + // transform-regnerator plugin (which is enabled by es2015). + "transform-async-to-bluebird", + + // This makes sure that the regenerator runtime is available to + // the transpiled code. + "transform-runtime", + ], +} diff --git a/matrix-js-sdk/.buildkite/pipeline.yaml b/matrix-js-sdk/.buildkite/pipeline.yaml new file mode 100644 index 000000000..4909aabde --- /dev/null +++ b/matrix-js-sdk/.buildkite/pipeline.yaml @@ -0,0 +1,34 @@ +steps: + - label: ":eslint: Lint" + command: + - "yarn install" + - "yarn lint" + plugins: + - docker#v3.0.1: + image: "node:10" + + - label: ":karma: Tests" + command: + - "yarn install" + - "yarn test" + plugins: + - docker#v3.0.1: + image: "node:10" + + - label: "📃 Docs" + command: + - "yarn install" + - "yarn gendoc" + plugins: + - docker#v3.0.1: + image: "node:10" + + - wait + + - label: "🐴 Trigger matrix-react-sdk" + trigger: "matrix-react-sdk" + branches: "develop" + build: + branch: "develop" + message: "[js-sdk] ${BUILDKITE_MESSAGE}" + async: true diff --git a/matrix-js-sdk/.editorconfig b/matrix-js-sdk/.editorconfig new file mode 100644 index 000000000..880331a09 --- /dev/null +++ b/matrix-js-sdk/.editorconfig @@ -0,0 +1,23 @@ +# Copyright 2017 Aviral Dasgupta +# +# 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. + +root = true + +[*] +charset=utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true diff --git a/matrix-js-sdk/.eslintrc.js b/matrix-js-sdk/.eslintrc.js new file mode 100644 index 000000000..9a752b14f --- /dev/null +++ b/matrix-js-sdk/.eslintrc.js @@ -0,0 +1,86 @@ +module.exports = { + parser: "babel-eslint", // now needed for class properties + parserOptions: { + sourceType: "module", + ecmaFeatures: { + } + }, + env: { + browser: true, + node: true, + + // babel's transform-runtime converts references to ES6 globals such as + // Promise and Map to core-js polyfills, so we can use ES6 globals. + es6: true, + }, + extends: ["eslint:recommended", "google"], + plugins: [ + "babel", + ], + rules: { + // rules we've always adhered to or now do + "max-len": ["error", { + code: 90, + ignoreComments: true, + }], + curly: ["error", "multi-line"], + "prefer-const": ["error"], + "comma-dangle": ["error", { + arrays: "always-multiline", + objects: "always-multiline", + imports: "always-multiline", + exports: "always-multiline", + functions: "always-multiline", + }], + + // loosen jsdoc requirements a little + "require-jsdoc": ["error", { + require: { + FunctionDeclaration: false, + } + }], + "valid-jsdoc": ["error", { + requireParamDescription: false, + requireReturn: false, + requireReturnDescription: false, + }], + + // rules we do not want from eslint-recommended + "no-console": ["off"], + "no-constant-condition": ["off"], + "no-empty": ["error", { "allowEmptyCatch": true }], + + // rules we do not want from the google styleguide + "object-curly-spacing": ["off"], + "spaced-comment": ["off"], + "guard-for-in": ["off"], + + // in principle we prefer single quotes, but life is too short + quotes: ["off"], + + // rules we'd ideally like to adhere to, but the current + // code does not (in most cases because it's still ES5) + // we set these to warnings, and assert that the number + // of warnings doesn't exceed a given threshold + "no-var": ["warn"], + "brace-style": ["warn", "1tbs", {"allowSingleLine": true}], + "prefer-rest-params": ["warn"], + "prefer-spread": ["warn"], + "one-var": ["warn"], + "padded-blocks": ["warn"], + "no-extend-native": ["warn"], + "camelcase": ["warn"], + "no-multi-spaces": ["error", { "ignoreEOLComments": true }], + "space-before-function-paren": ["error", { + "anonymous": "never", + "named": "never", + "asyncArrow": "always", + }], + "arrow-parens": "off", + + // eslint's built in no-invalid-this rule breaks with class properties + "no-invalid-this": "off", + // so we replace it with a version that is class property aware + "babel/no-invalid-this": "error", + } +} diff --git a/matrix-js-sdk/.github/FUNDING.yml b/matrix-js-sdk/.github/FUNDING.yml new file mode 100644 index 000000000..afc29f014 --- /dev/null +++ b/matrix-js-sdk/.github/FUNDING.yml @@ -0,0 +1,2 @@ +patreon: matrixdotorg +liberapay: matrixdotorg diff --git a/matrix-js-sdk/.gitignore b/matrix-js-sdk/.gitignore new file mode 100644 index 000000000..d698611e4 --- /dev/null +++ b/matrix-js-sdk/.gitignore @@ -0,0 +1,20 @@ +/.jsdocbuild +/.jsdoc + +node_modules +/.npmrc +/*.log +package-lock.json +.lock-wscript +build/Release +coverage +lib-cov +out +reports +/dist +/lib +/specbuild + +# version file and tarball created by `npm pack` / `yarn pack` +/git-revision.txt +/matrix-js-sdk-*.tgz diff --git a/matrix-js-sdk/.istanbul.yml b/matrix-js-sdk/.istanbul.yml new file mode 100644 index 000000000..17e759f6e --- /dev/null +++ b/matrix-js-sdk/.istanbul.yml @@ -0,0 +1,2 @@ +instrumentation: + compact: false diff --git a/matrix-js-sdk/CHANGELOG.md b/matrix-js-sdk/CHANGELOG.md new file mode 100644 index 000000000..b89b762a2 --- /dev/null +++ b/matrix-js-sdk/CHANGELOG.md @@ -0,0 +1,2283 @@ +Changes in [2.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.2.0) (2019-07-18) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.2.0-rc.2...v2.2.0) + + * Upgrade lodash dependencies + +Changes in [2.2.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.2.0-rc.2) (2019-07-12) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.2.0-rc.1...v2.2.0-rc.2) + + * Fix regression from 2.2.0-rc.1 in request to /devices + [\#995](https://github.com/matrix-org/matrix-js-sdk/pull/995) + +Changes in [2.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.2.0-rc.1) (2019-07-12) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.1.1...v2.2.0-rc.1) + + * End the verification timer when verification is done + [\#993](https://github.com/matrix-org/matrix-js-sdk/pull/993) + * Stabilize usage of stably stable APIs (in a stable way) + [\#990](https://github.com/matrix-org/matrix-js-sdk/pull/990) + * Expose original_event for /relations + [\#987](https://github.com/matrix-org/matrix-js-sdk/pull/987) + * Process ephemeral events outside timeline handling + [\#989](https://github.com/matrix-org/matrix-js-sdk/pull/989) + * Don't accept any locally known edits earlier than the last known server-side + aggregated edit + [\#986](https://github.com/matrix-org/matrix-js-sdk/pull/986) + * Get edit date transparently from server aggregations or local echo + [\#984](https://github.com/matrix-org/matrix-js-sdk/pull/984) + * Add a function to flag keys for backup without scheduling a backup + [\#982](https://github.com/matrix-org/matrix-js-sdk/pull/982) + * Block read marker and read receipt from advancing into pending events + [\#981](https://github.com/matrix-org/matrix-js-sdk/pull/981) + * Upgrade dependencies + [\#977](https://github.com/matrix-org/matrix-js-sdk/pull/977) + * Add default push rule to ignore reactions + [\#976](https://github.com/matrix-org/matrix-js-sdk/pull/976) + * Fix exception whilst syncing + [\#979](https://github.com/matrix-org/matrix-js-sdk/pull/979) + * Include the error object when raising Session.logged_out + [\#975](https://github.com/matrix-org/matrix-js-sdk/pull/975) + +Changes in [2.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.1.1) (2019-07-11) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.1.0...v2.1.1) + + * Process emphemeral events outside timeline handling + [\#989](https://github.com/matrix-org/matrix-js-sdk/pull/989) + +Changes in [2.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.1.0) (2019-07-08) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.1.0-rc.1...v2.1.0) + + * Fix exception whilst syncing + [\#979](https://github.com/matrix-org/matrix-js-sdk/pull/979) + +Changes in [2.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.1.0-rc.1) (2019-07-03) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.0.1...v2.1.0-rc.1) + + * Handle self read receipts for fixing e2e notification counts + [\#974](https://github.com/matrix-org/matrix-js-sdk/pull/974) + * Add redacts field to event.toJSON + [\#973](https://github.com/matrix-org/matrix-js-sdk/pull/973) + * Handle associated event send failures + [\#972](https://github.com/matrix-org/matrix-js-sdk/pull/972) + * Remove irrelevant debug line from timeline handling + [\#971](https://github.com/matrix-org/matrix-js-sdk/pull/971) + * Handle relations in encrypted rooms + [\#969](https://github.com/matrix-org/matrix-js-sdk/pull/969) + * Relations endpoint support + [\#967](https://github.com/matrix-org/matrix-js-sdk/pull/967) + * Disable event encryption for reactions + [\#968](https://github.com/matrix-org/matrix-js-sdk/pull/968) + * Change the known safe room version to version 4 + [\#966](https://github.com/matrix-org/matrix-js-sdk/pull/966) + * Check for lazy-loading support in the spec versions instead + [\#965](https://github.com/matrix-org/matrix-js-sdk/pull/965) + * Use camelCase instead of underscore + [\#963](https://github.com/matrix-org/matrix-js-sdk/pull/963) + * Time out verification attempts after 10 minutes of inactivity + [\#961](https://github.com/matrix-org/matrix-js-sdk/pull/961) + * Don't handle key verification requests which are immediately cancelled + [\#962](https://github.com/matrix-org/matrix-js-sdk/pull/962) + +Changes in [2.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.0.1) (2019-06-19) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.0.1-rc.2...v2.0.1) + + No changes since rc.2 + +Changes in [2.0.1-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.0.1-rc.2) (2019-06-18) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.0.1-rc.1...v2.0.1-rc.2) + + * return 'sending' status for an event that is only locally redacted + [\#960](https://github.com/matrix-org/matrix-js-sdk/pull/960) + * Key verification request fixes + [\#954](https://github.com/matrix-org/matrix-js-sdk/pull/954) + * Add flag to force saving sync store + [\#956](https://github.com/matrix-org/matrix-js-sdk/pull/956) + * Expose the inhibit_login flag to register + [\#953](https://github.com/matrix-org/matrix-js-sdk/pull/953) + * Support redactions and relations of/with unsent events. + [\#947](https://github.com/matrix-org/matrix-js-sdk/pull/947) + +Changes in [2.0.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.0.1-rc.1) (2019-06-12) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.0.0...v2.0.1-rc.1) + + * Fix content uploads for modern browsers + [\#952](https://github.com/matrix-org/matrix-js-sdk/pull/952) + * Don't overlap auth submissions with polls + [\#951](https://github.com/matrix-org/matrix-js-sdk/pull/951) + * Add funding details for GitHub sponsor button + [\#945](https://github.com/matrix-org/matrix-js-sdk/pull/945) + * Fix backup sig validation with multiple sigs + [\#944](https://github.com/matrix-org/matrix-js-sdk/pull/944) + * Don't send another token request while one's in flight + [\#943](https://github.com/matrix-org/matrix-js-sdk/pull/943) + * Don't poll UI auth again until current poll finishes + [\#942](https://github.com/matrix-org/matrix-js-sdk/pull/942) + * Provide the discovered URLs when a liveliness error occurs + [\#938](https://github.com/matrix-org/matrix-js-sdk/pull/938) + * Encode event IDs when redacting events + [\#941](https://github.com/matrix-org/matrix-js-sdk/pull/941) + * add missing logger + [\#940](https://github.com/matrix-org/matrix-js-sdk/pull/940) + * verification: don't error if we don't know about some keys + [\#939](https://github.com/matrix-org/matrix-js-sdk/pull/939) + * Local echo for redactions + [\#937](https://github.com/matrix-org/matrix-js-sdk/pull/937) + * Refresh safe room versions when the server looks more modern than us + [\#934](https://github.com/matrix-org/matrix-js-sdk/pull/934) + * Add v4 as a safe room version + [\#935](https://github.com/matrix-org/matrix-js-sdk/pull/935) + * Disable guard-for-in rule + [\#933](https://github.com/matrix-org/matrix-js-sdk/pull/933) + * Extend loglevel logging for the whole project + [\#924](https://github.com/matrix-org/matrix-js-sdk/pull/924) + * fix(login): saves access_token and user_id after login for all login types + [\#930](https://github.com/matrix-org/matrix-js-sdk/pull/930) + * Do not try to request thumbnails with non-integer sizes + [\#929](https://github.com/matrix-org/matrix-js-sdk/pull/929) + * Revert "Add a bunch of debugging to .well-known IS validation" + [\#928](https://github.com/matrix-org/matrix-js-sdk/pull/928) + * Add a bunch of debugging to .well-known IS validation + [\#927](https://github.com/matrix-org/matrix-js-sdk/pull/927) + +Changes in [2.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.0.0) (2019-05-31) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.2.0...v2.0.0) + +BREAKING CHANGES +---------------- + + * This package now publishes in ES6 / ES2015 syntax to NPM + * Saves access_token and user_id after login for all login types + [\#932](https://github.com/matrix-org/matrix-js-sdk/pull/932) + * Fix recovery key encoding for base-x 3.0.5 + [\#931](https://github.com/matrix-org/matrix-js-sdk/pull/931) + +Changes in [1.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.2.0) (2019-05-29) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.2.0-rc.1...v1.2.0) + + +Changes in [1.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.2.0-rc.1) (2019-05-23) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.1.0...v1.2.0-rc.1) + + * interactive-auth now handles requesting email tokens + [\#926](https://github.com/matrix-org/matrix-js-sdk/pull/926) + * allow access to unreplaced message content + [\#923](https://github.com/matrix-org/matrix-js-sdk/pull/923) + * Add method to retrieve replacing event + [\#922](https://github.com/matrix-org/matrix-js-sdk/pull/922) + * More logging when signature verification fails + [\#921](https://github.com/matrix-org/matrix-js-sdk/pull/921) + * Local echo for m.replace relations + [\#920](https://github.com/matrix-org/matrix-js-sdk/pull/920) + * Track relations as pending and remove when cancelled + [\#919](https://github.com/matrix-org/matrix-js-sdk/pull/919) + * Add stringify helper to summarise events when debugging + [\#916](https://github.com/matrix-org/matrix-js-sdk/pull/916) + * Message editing: filter out replacements for senders that are not the + original sender + [\#918](https://github.com/matrix-org/matrix-js-sdk/pull/918) + * Wait until decrypt before aggregating + [\#917](https://github.com/matrix-org/matrix-js-sdk/pull/917) + * Message editing: mark original event as replaced instead of replacing the + event object + [\#914](https://github.com/matrix-org/matrix-js-sdk/pull/914) + * Support for replacing message through m.replace relationship. + [\#913](https://github.com/matrix-org/matrix-js-sdk/pull/913) + * Use a short timeout for .well-known requests + [\#912](https://github.com/matrix-org/matrix-js-sdk/pull/912) + * Redaction and change events for relations + [\#911](https://github.com/matrix-org/matrix-js-sdk/pull/911) + * Add basic read path for relations + [\#910](https://github.com/matrix-org/matrix-js-sdk/pull/910) + * Add a concept of default push rules, using it for tombstone notifications + [\#860](https://github.com/matrix-org/matrix-js-sdk/pull/860) + * yarn upgrade + [\#907](https://github.com/matrix-org/matrix-js-sdk/pull/907) + +Changes in [1.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.1.0) (2019-05-07) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.1.0-rc.1...v1.1.0) + + * No Changes since rc.1 + +Changes in [1.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.1.0-rc.1) (2019-04-30) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.4...v1.1.0-rc.1) + + * use the release version of olm 3.1.0 + [\#903](https://github.com/matrix-org/matrix-js-sdk/pull/903) + * Use new Olm repo link in README + [\#901](https://github.com/matrix-org/matrix-js-sdk/pull/901) + * Support being fed a .well-known config object for validation + [\#897](https://github.com/matrix-org/matrix-js-sdk/pull/897) + * emit self-membership event at end of handling sync update + [\#900](https://github.com/matrix-org/matrix-js-sdk/pull/900) + * Use packages.matrix.org for Olm + [\#898](https://github.com/matrix-org/matrix-js-sdk/pull/898) + * Fix tests on develop + [\#899](https://github.com/matrix-org/matrix-js-sdk/pull/899) + * Stop syncing when the token is invalid + [\#895](https://github.com/matrix-org/matrix-js-sdk/pull/895) + * change event redact, POST request to PUT request + [\#887](https://github.com/matrix-org/matrix-js-sdk/pull/887) + * Expose better autodiscovery error messages + [\#894](https://github.com/matrix-org/matrix-js-sdk/pull/894) + * Explicitly guard store usage during sync startup + [\#892](https://github.com/matrix-org/matrix-js-sdk/pull/892) + * Flag v3 rooms as safe + [\#893](https://github.com/matrix-org/matrix-js-sdk/pull/893) + * Cache failed capabilities lookups for shorter amounts of time + [\#890](https://github.com/matrix-org/matrix-js-sdk/pull/890) + * Fix highlight notifications for unencrypted rooms + [\#891](https://github.com/matrix-org/matrix-js-sdk/pull/891) + * Document checking crypto state before using `hasUnverifiedDevices` + [\#889](https://github.com/matrix-org/matrix-js-sdk/pull/889) + * Add logging to sync startup path + [\#888](https://github.com/matrix-org/matrix-js-sdk/pull/888) + * Track e2e highlights better, particularly in 'Mentions Only' rooms + [\#886](https://github.com/matrix-org/matrix-js-sdk/pull/886) + * support both the incorrect and correct MAC methods + [\#882](https://github.com/matrix-org/matrix-js-sdk/pull/882) + * Refuse to set forwards pagination token on live timeline + [\#885](https://github.com/matrix-org/matrix-js-sdk/pull/885) + * Degrade `IndexedDBStore` back to memory only on failure + [\#884](https://github.com/matrix-org/matrix-js-sdk/pull/884) + * Refuse to link live timelines into the forwards/backwards position when + either is invalid + [\#877](https://github.com/matrix-org/matrix-js-sdk/pull/877) + * Key backup logging improvements + [\#883](https://github.com/matrix-org/matrix-js-sdk/pull/883) + * Don't assume aborts are always from txn.abort() + [\#880](https://github.com/matrix-org/matrix-js-sdk/pull/880) + * Add a bunch of logging + [\#878](https://github.com/matrix-org/matrix-js-sdk/pull/878) + * Refuse splicing the live timeline into a broken position + [\#873](https://github.com/matrix-org/matrix-js-sdk/pull/873) + * Add existence check to local storage based crypto store + [\#872](https://github.com/matrix-org/matrix-js-sdk/pull/872) + +Changes in [1.0.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.4) (2019-04-08) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.3...v1.0.4) + + * Hotfix: more logging and potential fixes for timeline corruption issue, see ticket https://github.com/vector-im/riot-web/issues/8593. + +Changes in [1.0.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.3) (2019-04-01) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.3-rc.1...v1.0.3) + + * Add existence check to local storage based crypto store + [\#874](https://github.com/matrix-org/matrix-js-sdk/pull/874) + +Changes in [1.0.3-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.3-rc.1) (2019-03-27) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.2...v1.0.3-rc.1) + + * Add IndexedDB existence checks + [\#871](https://github.com/matrix-org/matrix-js-sdk/pull/871) + * Emit sync errors for capturing by clients + [\#869](https://github.com/matrix-org/matrix-js-sdk/pull/869) + * Add functions for getting room upgrade history and leaving those rooms + [\#868](https://github.com/matrix-org/matrix-js-sdk/pull/868) + * Clarify the meaning of 'real name' for contribution + [\#867](https://github.com/matrix-org/matrix-js-sdk/pull/867) + * Remove `sessionStore` to `cryptoStore` migration path + [\#865](https://github.com/matrix-org/matrix-js-sdk/pull/865) + * Add debugging for spurious room version warnings + [\#866](https://github.com/matrix-org/matrix-js-sdk/pull/866) + * Add investigation notes for browser storage + [\#864](https://github.com/matrix-org/matrix-js-sdk/pull/864) + * make sure resolve object is defined before calling it + [\#862](https://github.com/matrix-org/matrix-js-sdk/pull/862) + * Rename `MatrixInMemoryStore` to `MemoryStore` + [\#861](https://github.com/matrix-org/matrix-js-sdk/pull/861) + * Use Buildkite for CI + [\#859](https://github.com/matrix-org/matrix-js-sdk/pull/859) + * only create one session at a time per device + [\#857](https://github.com/matrix-org/matrix-js-sdk/pull/857) + +Changes in [1.0.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.2) (2019-03-18) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.2-rc.1...v1.0.2) + + * No changes since rc.1 + +Changes in [1.0.2-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.2-rc.1) (2019-03-13) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.1...v1.0.2-rc.1) + + * Use modern Yarn version on Travis CI + [\#858](https://github.com/matrix-org/matrix-js-sdk/pull/858) + * Switch to `yarn` for dependency management + [\#856](https://github.com/matrix-org/matrix-js-sdk/pull/856) + * More key request fixes + [\#855](https://github.com/matrix-org/matrix-js-sdk/pull/855) + * Calculate encrypted notification counts + [\#851](https://github.com/matrix-org/matrix-js-sdk/pull/851) + * Update dependencies + [\#854](https://github.com/matrix-org/matrix-js-sdk/pull/854) + * make sure key requests get sent + [\#850](https://github.com/matrix-org/matrix-js-sdk/pull/850) + * Use 'ideal' rather than 'exact' for deviceid + [\#852](https://github.com/matrix-org/matrix-js-sdk/pull/852) + * handle partially-shared sessions better + [\#848](https://github.com/matrix-org/matrix-js-sdk/pull/848) + +Changes in [1.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.1) (2019-03-06) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.1-rc.2...v1.0.1) + + * No changes since rc.2 + +Changes in [1.0.1-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.1-rc.2) (2019-03-05) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.1-rc.1...v1.0.1-rc.2) + + * dont swallow txn errors in crypto store + [\#853](https://github.com/matrix-org/matrix-js-sdk/pull/853) + * Don't swallow txn errors in crypto store + [\#849](https://github.com/matrix-org/matrix-js-sdk/pull/849) + +Changes in [1.0.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.1-rc.1) (2019-02-28) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.0...v1.0.1-rc.1) + + * Fix "e is undefined" masking the original error in MegolmDecryption + [\#847](https://github.com/matrix-org/matrix-js-sdk/pull/847) + +Changes in [1.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.0) (2019-02-14) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.0-rc.2...v1.0.0) + + * Try again to commit package-lock.json + [\#841](https://github.com/matrix-org/matrix-js-sdk/pull/841) + +Changes in [1.0.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.0-rc.2) (2019-02-14) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v1.0.0-rc.1...v1.0.0-rc.2) + + * Release script: commit package-lock.json + [\#839](https://github.com/matrix-org/matrix-js-sdk/pull/839) + * Add method to force re-check of key backup + [\#840](https://github.com/matrix-org/matrix-js-sdk/pull/840) + * Fix: dont check for unverified devices in left members + [\#838](https://github.com/matrix-org/matrix-js-sdk/pull/838) + +Changes in [1.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v1.0.0-rc.1) (2019-02-08) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.14.3...v1.0.0-rc.1) + + * change hex SAS verification to decimal and emoji + [\#837](https://github.com/matrix-org/matrix-js-sdk/pull/837) + * Trust on decrypt + [\#836](https://github.com/matrix-org/matrix-js-sdk/pull/836) + * Always track our own devices + [\#835](https://github.com/matrix-org/matrix-js-sdk/pull/835) + * Make linting rules more consistent + [\#834](https://github.com/matrix-org/matrix-js-sdk/pull/834) + * add method to room to check for unverified devices + [\#833](https://github.com/matrix-org/matrix-js-sdk/pull/833) + * Merge redesign into develop + [\#831](https://github.com/matrix-org/matrix-js-sdk/pull/831) + * Supporting infrastructure for educated decisions on when to upgrade rooms + [\#830](https://github.com/matrix-org/matrix-js-sdk/pull/830) + * Include signature info for unknown devices + [\#826](https://github.com/matrix-org/matrix-js-sdk/pull/826) + * Flag v2 rooms as "safe" + [\#828](https://github.com/matrix-org/matrix-js-sdk/pull/828) + * Update ESLint + [\#821](https://github.com/matrix-org/matrix-js-sdk/pull/821) + +Changes in [0.14.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.14.3) (2019-01-22) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.14.3-rc.1...v0.14.3) + + * No changes since rc.1 + +Changes in [0.14.3-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.14.3-rc.1) (2019-01-17) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.14.2...v0.14.3-rc.1) + + * Merge develop into experimental + [\#815](https://github.com/matrix-org/matrix-js-sdk/pull/815) + * Add a getAllEndToEndSessions to crypto store + [\#812](https://github.com/matrix-org/matrix-js-sdk/pull/812) + * T3chguy/fix displayname logic + [\#668](https://github.com/matrix-org/matrix-js-sdk/pull/668) + * Contributing: Note that rebase lets you mass signoff commits + [\#814](https://github.com/matrix-org/matrix-js-sdk/pull/814) + * take into account homoglyphs when calculating similar display names + [\#672](https://github.com/matrix-org/matrix-js-sdk/pull/672) + * Emit for key backup failures + [\#809](https://github.com/matrix-org/matrix-js-sdk/pull/809) + * emit oldEventId on "updatePendingEvent" + [\#646](https://github.com/matrix-org/matrix-js-sdk/pull/646) + * Add getThirdpartyUser to base api + [\#589](https://github.com/matrix-org/matrix-js-sdk/pull/589) + * Support custom status messages + [\#805](https://github.com/matrix-org/matrix-js-sdk/pull/805) + * Extra checks to avoid release script blowing up mid-process. + [\#749](https://github.com/matrix-org/matrix-js-sdk/pull/749) + * Move glob regex utilities out of the pushprocessor and into a more generic + place + [\#800](https://github.com/matrix-org/matrix-js-sdk/pull/800) + +Changes in [0.14.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.14.2) (2018-12-10) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.14.2-rc.1...v0.14.2) + + * No changes since rc.1 + +Changes in [0.14.2-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.14.2-rc.1) (2018-12-06) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.14.1...v0.14.2-rc.1) + + * fix some assertions in e2e backup unit test + [\#794](https://github.com/matrix-org/matrix-js-sdk/pull/794) + * Config should be called with auth + [\#798](https://github.com/matrix-org/matrix-js-sdk/pull/798) + * Don't re-establish sessions with unknown devices + [\#792](https://github.com/matrix-org/matrix-js-sdk/pull/792) + * e2e key backups + [\#684](https://github.com/matrix-org/matrix-js-sdk/pull/684) + * WIP: online incremental megolm backups + [\#595](https://github.com/matrix-org/matrix-js-sdk/pull/595) + * Support for e2e key backups + [\#736](https://github.com/matrix-org/matrix-js-sdk/pull/736) + * Passphrase Support for e2e backups + [\#786](https://github.com/matrix-org/matrix-js-sdk/pull/786) + * Add 'getSsoLoginUrl' function + [\#783](https://github.com/matrix-org/matrix-js-sdk/pull/783) + * Fix: don't set the room name to null when heroes are missing. + [\#784](https://github.com/matrix-org/matrix-js-sdk/pull/784) + * Handle crypto db version upgrades + [\#785](https://github.com/matrix-org/matrix-js-sdk/pull/785) + * Restart broken Olm sessions + [\#780](https://github.com/matrix-org/matrix-js-sdk/pull/780) + * Use the last olm session that got a message + [\#776](https://github.com/matrix-org/matrix-js-sdk/pull/776) + +Changes in [0.14.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.14.1) (2018-11-22) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.14.0...v0.14.1) + + * Warning when crypto DB is too new to use. + +Changes in [0.14.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.14.0) (2018-11-19) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.14.0-rc.1...v0.14.0) + + * No changes since rc.1 + +Changes in [0.14.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.14.0-rc.1) (2018-11-15) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.13.1...v0.14.0-rc.1) + +BREAKING CHANGE +---------------- + + * js-sdk now uses Olm 3.0. Apps using Olm must update to 3.0 to + continue using Olm with the js-sdk. The js-sdk will call Olm's + init() method when the client is started. + +All Changes +----------- + + * Prevent messages from being sent if other messages have failed to send + [\#781](https://github.com/matrix-org/matrix-js-sdk/pull/781) + * A unit test for olm + [\#777](https://github.com/matrix-org/matrix-js-sdk/pull/777) + * Set access_token and user_id after login in with username and password. + [\#778](https://github.com/matrix-org/matrix-js-sdk/pull/778) + * Add function to get currently joined rooms. + [\#779](https://github.com/matrix-org/matrix-js-sdk/pull/779) + * Remove the request-only stuff we don't need anymore + [\#775](https://github.com/matrix-org/matrix-js-sdk/pull/775) + * Manually construct query strings for browser-request instances + [\#770](https://github.com/matrix-org/matrix-js-sdk/pull/770) + * Fix: correctly check for crypto being present + [\#769](https://github.com/matrix-org/matrix-js-sdk/pull/769) + * Update babel-eslint to 8.1.1 + [\#768](https://github.com/matrix-org/matrix-js-sdk/pull/768) + * Support `request` in the browser and support supplying servers to try in + joinRoom() + [\#764](https://github.com/matrix-org/matrix-js-sdk/pull/764) + * loglevel should be a normal dependency + [\#767](https://github.com/matrix-org/matrix-js-sdk/pull/767) + * Stop devicelist when client is stopped + [\#766](https://github.com/matrix-org/matrix-js-sdk/pull/766) + * Update to WebAssembly-powered Olm + [\#743](https://github.com/matrix-org/matrix-js-sdk/pull/743) + * Logging lib. Fixes #332 + [\#763](https://github.com/matrix-org/matrix-js-sdk/pull/763) + * Use new stop() method on matrix-mock-request + [\#765](https://github.com/matrix-org/matrix-js-sdk/pull/765) + +Changes in [0.13.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.13.1) (2018-11-14) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.13.0...v0.13.1) + + * Add function to get currently joined rooms. + [\#779](https://github.com/matrix-org/matrix-js-sdk/pull/779) + +Changes in [0.13.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.13.0) (2018-11-15) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.12.1...v0.13.0) + +BREAKING CHANGE +---------------- + * `MatrixClient::login` now sets client `access_token` and `user_id` following successful login with username and password. + +Changes in [0.12.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.12.1) (2018-10-29) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.12.1-rc.1...v0.12.1) + + * No changes since rc.1 + +Changes in [0.12.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.12.1-rc.1) (2018-10-24) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.12.0...v0.12.1-rc.1) + + * Add repository type to package.json to make it valid + [\#762](https://github.com/matrix-org/matrix-js-sdk/pull/762) + * Add getMediaConfig() + [\#761](https://github.com/matrix-org/matrix-js-sdk/pull/761) + * add new examples, to be expanded into a post + [\#739](https://github.com/matrix-org/matrix-js-sdk/pull/739) + +Changes in [0.12.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.12.0) (2018-10-16) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.12.0-rc.1...v0.12.0) + + * No changes since rc.1 + +Changes in [0.12.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.12.0-rc.1) (2018-10-11) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.1...v0.12.0-rc.1) + +BREAKING CHANGES +---------------- + * If js-sdk finds data in the store that is incompatible with the options currently being used, + it will emit sync state ERROR with an error of type InvalidStoreError. It will also stop trying + to sync in this situation: the app must stop the client and then either clear the store or + change the options (in this case, enable or disable lazy loading of members) and then start + the client again. + +All Changes +----------- + + * never replace /sync'ed memberships with OOB ones + [\#760](https://github.com/matrix-org/matrix-js-sdk/pull/760) + * Don't fail to start up if lazy load check fails + [\#759](https://github.com/matrix-org/matrix-js-sdk/pull/759) + * Make e2e work on Edge + [\#754](https://github.com/matrix-org/matrix-js-sdk/pull/754) + * throw error with same name and message over idb worker boundary + [\#758](https://github.com/matrix-org/matrix-js-sdk/pull/758) + * Default to a room version of 1 when there is no room create event + [\#755](https://github.com/matrix-org/matrix-js-sdk/pull/755) + * Silence bluebird warnings + [\#757](https://github.com/matrix-org/matrix-js-sdk/pull/757) + * allow non-ff merge from release branch into master + [\#750](https://github.com/matrix-org/matrix-js-sdk/pull/750) + * Reject with the actual error on indexeddb error + [\#751](https://github.com/matrix-org/matrix-js-sdk/pull/751) + * Update mocha to v5 + [\#744](https://github.com/matrix-org/matrix-js-sdk/pull/744) + * disable lazy loading for guests as they cant create filters + [\#748](https://github.com/matrix-org/matrix-js-sdk/pull/748) + * Revert "Add getMediaLimits to client" + [\#745](https://github.com/matrix-org/matrix-js-sdk/pull/745) + +Changes in [0.11.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.1) (2018-10-01) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.1-rc.1...v0.11.1) + + * No changes since rc.1 + +Changes in [0.11.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.1-rc.1) (2018-09-27) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.0...v0.11.1-rc.1) + + * make usage of hub compatible with latest version (2.5) + [\#747](https://github.com/matrix-org/matrix-js-sdk/pull/747) + * Detect when lazy loading has been toggled in client.startClient + [\#746](https://github.com/matrix-org/matrix-js-sdk/pull/746) + * Add getMediaLimits to client + [\#644](https://github.com/matrix-org/matrix-js-sdk/pull/644) + * Split npm start into an init and watch script + [\#742](https://github.com/matrix-org/matrix-js-sdk/pull/742) + * Revert "room name should only take canonical alias into account" + [\#738](https://github.com/matrix-org/matrix-js-sdk/pull/738) + * fix display name disambiguation with LL + [\#737](https://github.com/matrix-org/matrix-js-sdk/pull/737) + * Introduce Room.myMembership event + [\#735](https://github.com/matrix-org/matrix-js-sdk/pull/735) + * room name should only take canonical alias into account + [\#733](https://github.com/matrix-org/matrix-js-sdk/pull/733) + * state events from context response were not wrapped in a MatrixEvent + [\#732](https://github.com/matrix-org/matrix-js-sdk/pull/732) + * Reduce amount of promises created when inserting members + [\#724](https://github.com/matrix-org/matrix-js-sdk/pull/724) + * dont wait for LL members to be stored to resolve the members + [\#726](https://github.com/matrix-org/matrix-js-sdk/pull/726) + * RoomState.members emitted with wrong argument order for OOB members + [\#728](https://github.com/matrix-org/matrix-js-sdk/pull/728) + +Changes in [0.11.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.0) (2018-09-10) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.0-rc.1...v0.11.0) + +BREAKING CHANGES +---------------- + * v0.11.0-rc.1 introduced some breaking changes - see the respective release notes. + +No changes since rc.1 + +Changes in [0.11.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.0-rc.1) (2018-09-07) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.9...v0.11.0-rc.1) + + * Support for lazy loading members. This should improve performance for + users who joined big rooms a lot. Pass to `lazyLoadMembers = true` option when calling `startClient`. + +BREAKING CHANGES +---------------- + + * `MatrixClient::startClient` now returns a Promise. No method should be called on the client before that promise resolves. Before this method didn't return anything. + * A new `CATCHUP` sync state, emitted by `MatrixClient#"sync"` and returned by `MatrixClient::getSyncState()`, when doing initial sync after the `ERROR` state. See `MatrixClient` documentation for details. + * `RoomState::maySendEvent('m.room.message', userId)` & `RoomState::maySendMessage(userId)` do not check the membership of the user anymore, only the power level. To check if the syncing user is allowed to write in a room, use `Room::maySendMessage()` as `RoomState` is not always aware of the syncing user's membership anymore, in case lazy loading of members is enabled. + +All Changes +----------- + + * Only emit CATCHUP if recovering from conn error + [\#727](https://github.com/matrix-org/matrix-js-sdk/pull/727) + * Fix docstring for sync data.error + [\#725](https://github.com/matrix-org/matrix-js-sdk/pull/725) + * Re-apply "Don't rely on members to query if syncing user can post to room" + [\#723](https://github.com/matrix-org/matrix-js-sdk/pull/723) + * Revert "Don't rely on members to query if syncing user can post to room" + [\#721](https://github.com/matrix-org/matrix-js-sdk/pull/721) + * Don't rely on members to query if syncing user can post to room + [\#717](https://github.com/matrix-org/matrix-js-sdk/pull/717) + * Fixes for room.guessDMUserId + [\#719](https://github.com/matrix-org/matrix-js-sdk/pull/719) + * Fix filepanel also filtering main timeline with LL turned on. + [\#716](https://github.com/matrix-org/matrix-js-sdk/pull/716) + * Remove lazy loaded members when leaving room + [\#711](https://github.com/matrix-org/matrix-js-sdk/pull/711) + * Fix: show spinner again while recovering from connection error + [\#702](https://github.com/matrix-org/matrix-js-sdk/pull/702) + * Add method to query LL state in client + [\#714](https://github.com/matrix-org/matrix-js-sdk/pull/714) + * Fix: also load invited members when lazy loading members + [\#707](https://github.com/matrix-org/matrix-js-sdk/pull/707) + * Pass through function to discard megolm session + [\#704](https://github.com/matrix-org/matrix-js-sdk/pull/704) + +Changes in [0.10.9](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.9) (2018-09-03) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.9-rc.2...v0.10.9) + + * No changes since rc.2 + +Changes in [0.10.9-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.9-rc.2) (2018-08-31) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.9-rc.1...v0.10.9-rc.2) + + * Fix for "otherMember.getAvatarUrl is not a function" + [\#708](https://github.com/matrix-org/matrix-js-sdk/pull/708) + +Changes in [0.10.9-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.9-rc.1) (2018-08-30) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.8...v0.10.9-rc.1) + + * Fix DM avatar + [\#706](https://github.com/matrix-org/matrix-js-sdk/pull/706) + * Lazy loading: avoid loading members at initial sync for e2e rooms + [\#699](https://github.com/matrix-org/matrix-js-sdk/pull/699) + * Improve setRoomEncryption guard against multiple m.room.encryption st… + [\#700](https://github.com/matrix-org/matrix-js-sdk/pull/700) + * Revert "Lazy loading: don't block on setting up room crypto" + [\#698](https://github.com/matrix-org/matrix-js-sdk/pull/698) + * Lazy loading: don't block on setting up room crypto + [\#696](https://github.com/matrix-org/matrix-js-sdk/pull/696) + * Add getVisibleRooms() + [\#695](https://github.com/matrix-org/matrix-js-sdk/pull/695) + * Add wrapper around getJoinedMemberCount() + [\#697](https://github.com/matrix-org/matrix-js-sdk/pull/697) + * Api to fetch events via /room/.../event/.. + [\#694](https://github.com/matrix-org/matrix-js-sdk/pull/694) + * Support for room upgrades + [\#693](https://github.com/matrix-org/matrix-js-sdk/pull/693) + * Lazy loading of room members + [\#691](https://github.com/matrix-org/matrix-js-sdk/pull/691) + +Changes in [0.10.8](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.8) (2018-08-20) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.8-rc.1...v0.10.8) + + * No changes since rc.1 + +Changes in [0.10.8-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.8-rc.1) (2018-08-16) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.7...v0.10.8-rc.1) + + * Add getVersion to Room + [\#689](https://github.com/matrix-org/matrix-js-sdk/pull/689) + * Add getSyncStateData() + [\#680](https://github.com/matrix-org/matrix-js-sdk/pull/680) + * Send sync error to listener + [\#679](https://github.com/matrix-org/matrix-js-sdk/pull/679) + * make sure room.tags is always a valid object to avoid crashes + [\#675](https://github.com/matrix-org/matrix-js-sdk/pull/675) + * Fix infinite spinner upon joining a room + [\#673](https://github.com/matrix-org/matrix-js-sdk/pull/673) + +Changes in [0.10.7](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.7) (2018-07-30) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.7-rc.1...v0.10.7) + + * No changes since rc.1 + +Changes in [0.10.7-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.7-rc.1) (2018-07-24) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.6...v0.10.7-rc.1) + + * encrypt for invited users if history visibility allows. + [\#666](https://github.com/matrix-org/matrix-js-sdk/pull/666) + +Changes in [0.10.6](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.6) (2018-07-09) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.6-rc.1...v0.10.6) + + * No changes since rc.1 + +Changes in [0.10.6-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.6-rc.1) (2018-07-06) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.5...v0.10.6-rc.1) + + * Expose event decryption error via Event.decrypted event + [\#665](https://github.com/matrix-org/matrix-js-sdk/pull/665) + * Add decryption error codes to base.DecryptionError + [\#663](https://github.com/matrix-org/matrix-js-sdk/pull/663) + +Changes in [0.10.5](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.5) (2018-06-29) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.5-rc.1...v0.10.5) + + * No changes since rc.1 + +Changes in [0.10.5-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.5-rc.1) (2018-06-21) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.4...v0.10.5-rc.1) + + * fix auth header and filename=undefined + [\#659](https://github.com/matrix-org/matrix-js-sdk/pull/659) + * allow setting the output device for webrtc calls + [\#650](https://github.com/matrix-org/matrix-js-sdk/pull/650) + * arguments true and false are actually invalid + [\#596](https://github.com/matrix-org/matrix-js-sdk/pull/596) + * fix typo where `headers` was not being used and thus sent wrong content-type + [\#643](https://github.com/matrix-org/matrix-js-sdk/pull/643) + * fix some documentation typos + [\#642](https://github.com/matrix-org/matrix-js-sdk/pull/642) + +Changes in [0.10.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.4) (2018-06-12) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.4-rc.1...v0.10.4) + + * No changes since rc.1 + +Changes in [0.10.4-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.4-rc.1) (2018-06-06) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.3...v0.10.4-rc.1) + + * check whether notif level is undefined, because `0` is falsey + [\#651](https://github.com/matrix-org/matrix-js-sdk/pull/651) + +Changes in [0.10.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.3) (2018-05-25) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.3-rc.1...v0.10.3) + + * No changes since v0.10.3-rc.1 + +Changes in [0.10.3-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.3-rc.1) (2018-05-24) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.2...v0.10.3-rc.1) + +BREAKING CHANGE +--------------- + +The deprecated 'callback' parameter has been removed from MatrixBaseApis.deactivateAccount + + * Add `erase` option to deactivateAccount + [\#649](https://github.com/matrix-org/matrix-js-sdk/pull/649) + * Emit Session.no_consent when M_CONSENT_NOT_GIVEN received + [\#647](https://github.com/matrix-org/matrix-js-sdk/pull/647) + +Changes in [0.10.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.2) (2018-04-30) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.2-rc.1...v0.10.2) + + * No changes from rc.1 + +Changes in [0.10.2-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.2-rc.1) (2018-04-25) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.1...v0.10.2-rc.1) + + * Ignore inserts of dup inbound group sessions, pt 2 + [\#641](https://github.com/matrix-org/matrix-js-sdk/pull/641) + * Ignore inserts of duplicate inbound group sessions + [\#639](https://github.com/matrix-org/matrix-js-sdk/pull/639) + * Log IDB errors + [\#638](https://github.com/matrix-org/matrix-js-sdk/pull/638) + * Remove not very useful but veryv spammy log line + [\#632](https://github.com/matrix-org/matrix-js-sdk/pull/632) + * Switch event type to m.sticker. + [\#628](https://github.com/matrix-org/matrix-js-sdk/pull/628) + +Changes in [0.10.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.1) (2018-04-12) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.0...v0.10.1) + + * Log IDB errors + [\#638](https://github.com/matrix-org/matrix-js-sdk/pull/638) + * Ignore inserts of duplicate inbound group sessions + [\#639](https://github.com/matrix-org/matrix-js-sdk/pull/639) + * Ignore inserts of dup inbound group sessions, pt 2 + [\#641](https://github.com/matrix-org/matrix-js-sdk/pull/641) + +Changes in [0.10.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.0) (2018-04-11) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.0-rc.2...v0.10.0) + + * No changes + +Changes in [0.10.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.0-rc.2) (2018-04-09) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.0-rc.1...v0.10.0-rc.2) + + * Add wrapper for group join API + * Add wrapped API to set group join\_policy + +Changes in [0.10.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.10.0-rc.1) (2018-03-19) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.9.2...v0.10.0-rc.1) + + * Fix duplicated state events in timeline from peek + [\#630](https://github.com/matrix-org/matrix-js-sdk/pull/630) + * Create indexeddb worker when starting the store + [\#627](https://github.com/matrix-org/matrix-js-sdk/pull/627) + * Fix indexeddb logging + [\#626](https://github.com/matrix-org/matrix-js-sdk/pull/626) + * Don't do /keys/changes on incremental sync + [\#625](https://github.com/matrix-org/matrix-js-sdk/pull/625) + * Don't mark devicelist dirty unnecessarily + [\#623](https://github.com/matrix-org/matrix-js-sdk/pull/623) + * Cache the joined member count for a room state + [\#619](https://github.com/matrix-org/matrix-js-sdk/pull/619) + * Fix JS doc + [\#618](https://github.com/matrix-org/matrix-js-sdk/pull/618) + * Precompute push actions for state events + [\#617](https://github.com/matrix-org/matrix-js-sdk/pull/617) + * Fix bug where global "Never send to unverified..." is ignored + [\#616](https://github.com/matrix-org/matrix-js-sdk/pull/616) + * Intern legacy top-level 'membership' field + [\#615](https://github.com/matrix-org/matrix-js-sdk/pull/615) + * Don't synthesize RR for m.room.redaction as causes the RR to go missing. + [\#598](https://github.com/matrix-org/matrix-js-sdk/pull/598) + * Make Events create Dates on demand + [\#613](https://github.com/matrix-org/matrix-js-sdk/pull/613) + * Stop cloning events when adding to state + [\#612](https://github.com/matrix-org/matrix-js-sdk/pull/612) + * De-dup code: use the initialiseState function + [\#611](https://github.com/matrix-org/matrix-js-sdk/pull/611) + * Create sentinel members on-demand + [\#610](https://github.com/matrix-org/matrix-js-sdk/pull/610) + * Some more doc on how sentinels work + [\#609](https://github.com/matrix-org/matrix-js-sdk/pull/609) + * Migrate room encryption store to crypto store + [\#597](https://github.com/matrix-org/matrix-js-sdk/pull/597) + * add parameter to getIdentityServerUrl to strip the protocol for invites + [\#600](https://github.com/matrix-org/matrix-js-sdk/pull/600) + * Move Device Tracking Data to Crypto Store + [\#594](https://github.com/matrix-org/matrix-js-sdk/pull/594) + * Optimise pushprocessor + [\#591](https://github.com/matrix-org/matrix-js-sdk/pull/591) + * Set event error before emitting + [\#592](https://github.com/matrix-org/matrix-js-sdk/pull/592) + * Add event type for stickers [WIP] + [\#590](https://github.com/matrix-org/matrix-js-sdk/pull/590) + * Migrate inbound sessions to cryptostore + [\#587](https://github.com/matrix-org/matrix-js-sdk/pull/587) + * Disambiguate names if they contain an mxid + [\#588](https://github.com/matrix-org/matrix-js-sdk/pull/588) + * Check for sessions in indexeddb before migrating + [\#585](https://github.com/matrix-org/matrix-js-sdk/pull/585) + * Emit an event for crypto store migration + [\#586](https://github.com/matrix-org/matrix-js-sdk/pull/586) + * Supporting fixes For making UnknownDeviceDialog not pop up automatically + [\#575](https://github.com/matrix-org/matrix-js-sdk/pull/575) + * Move sessions to the crypto store + [\#584](https://github.com/matrix-org/matrix-js-sdk/pull/584) + * Change crypto store transaction API + [\#582](https://github.com/matrix-org/matrix-js-sdk/pull/582) + * Add some missed copyright notices + [\#581](https://github.com/matrix-org/matrix-js-sdk/pull/581) + * Move Olm account to IndexedDB + [\#579](https://github.com/matrix-org/matrix-js-sdk/pull/579) + * Fix logging of DecryptionErrors to be more useful + [\#580](https://github.com/matrix-org/matrix-js-sdk/pull/580) + * [BREAKING] Change the behaviour of the unverfied devices blacklist flag + [\#568](https://github.com/matrix-org/matrix-js-sdk/pull/568) + * Support set_presence=offline for syncing + [\#557](https://github.com/matrix-org/matrix-js-sdk/pull/557) + * Consider cases where the sender may not redact their own event + [\#556](https://github.com/matrix-org/matrix-js-sdk/pull/556) + +Changes in [0.9.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.9.2) (2017-12-04) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.9.1...v0.9.2) + + +Changes in [0.9.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.9.1) (2017-11-17) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.9.0...v0.9.1) + + * Fix the force TURN option + [\#577](https://github.com/matrix-org/matrix-js-sdk/pull/577) + +Changes in [0.9.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.9.0) (2017-11-15) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.9.0-rc.1...v0.9.0) + + +Changes in [0.9.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.9.0-rc.1) (2017-11-10) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.8.5...v0.9.0-rc.1) + + * Modify addRoomToGroup to allow setting isPublic, create alias + updateGroupRoomAssociation + [\#567](https://github.com/matrix-org/matrix-js-sdk/pull/567) + * Expose more functionality of pushprocessor + [\#565](https://github.com/matrix-org/matrix-js-sdk/pull/565) + * Function for working out notif trigger permission + [\#566](https://github.com/matrix-org/matrix-js-sdk/pull/566) + * keep track of event ID and timestamp of decrypted messages + [\#555](https://github.com/matrix-org/matrix-js-sdk/pull/555) + * Fix notifEvent computation + [\#564](https://github.com/matrix-org/matrix-js-sdk/pull/564) + * Fix power level of sentinel members + [\#563](https://github.com/matrix-org/matrix-js-sdk/pull/563) + * don't try to decrypt a redacted message (fixes vector-im/riot-web#3744) + [\#554](https://github.com/matrix-org/matrix-js-sdk/pull/554) + * Support room notifs + [\#562](https://github.com/matrix-org/matrix-js-sdk/pull/562) + * Fix the glob-to-regex code + [\#558](https://github.com/matrix-org/matrix-js-sdk/pull/558) + +Changes in [0.8.5](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.8.5) (2017-10-16) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.8.5-rc.1...v0.8.5) + + * Make unknown pushrule conditions not match + [\#559](https://github.com/matrix-org/matrix-js-sdk/pull/559) + +Changes in [0.8.5-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.8.5-rc.1) (2017-10-13) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.8.4...v0.8.5-rc.1) + + * Implement wrapper API for removing a room from a group + [\#553](https://github.com/matrix-org/matrix-js-sdk/pull/553) + * Fix typo which resulted in stuck key download requests + [\#552](https://github.com/matrix-org/matrix-js-sdk/pull/552) + * Store group when it's created + [\#549](https://github.com/matrix-org/matrix-js-sdk/pull/549) + * Luke/groups remove rooms users from summary + [\#548](https://github.com/matrix-org/matrix-js-sdk/pull/548) + * Clean on prepublish + [\#546](https://github.com/matrix-org/matrix-js-sdk/pull/546) + * Implement wrapper APIs for adding rooms to group summary + [\#545](https://github.com/matrix-org/matrix-js-sdk/pull/545) + +Changes in [0.8.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.8.4) (2017-09-21) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.8.3...v0.8.4) + + * Fix build issue + +Changes in [0.8.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.8.3) (2017-09-20) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.8.3-rc.1...v0.8.3) + + * No changes + +Changes in [0.8.3-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.8.3-rc.1) (2017-09-19) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.8.2...v0.8.3-rc.1) + + * consume trailing slash when creating Matrix Client in HS and IS urls + [\#526](https://github.com/matrix-org/matrix-js-sdk/pull/526) + * Add ignore users API + [\#539](https://github.com/matrix-org/matrix-js-sdk/pull/539) + * Upgrade to jsdoc 3.5.5 + [\#540](https://github.com/matrix-org/matrix-js-sdk/pull/540) + * Make re-emitting events much more memory efficient + [\#538](https://github.com/matrix-org/matrix-js-sdk/pull/538) + * Only re-emit events from Event objects if needed + [\#536](https://github.com/matrix-org/matrix-js-sdk/pull/536) + * Handle 'left' users in the deviceList mananagement + [\#535](https://github.com/matrix-org/matrix-js-sdk/pull/535) + * Factor out devicelist integration tests to a separate file + [\#534](https://github.com/matrix-org/matrix-js-sdk/pull/534) + * Refactor sync._sync as an async function + [\#533](https://github.com/matrix-org/matrix-js-sdk/pull/533) + * Add es6 to eslint environments + [\#532](https://github.com/matrix-org/matrix-js-sdk/pull/532) + +Changes in [0.8.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.8.2) (2017-08-24) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.8.1...v0.8.2) + + * Handle m.call.* events which are decrypted asynchronously + [\#530](https://github.com/matrix-org/matrix-js-sdk/pull/530) + * Re-emit events from, er, Event objects + [\#529](https://github.com/matrix-org/matrix-js-sdk/pull/529) + +Changes in [0.8.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.8.1) (2017-08-23) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.8.1-rc.1...v0.8.1) + + * [No changes] + +Changes in [0.8.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.8.1-rc.1) (2017-08-22) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.8.0...v0.8.1-rc.1) + + * Fix error handling in interactive-auth + [\#527](https://github.com/matrix-org/matrix-js-sdk/pull/527) + * Make lots of OlmDevice asynchronous + [\#524](https://github.com/matrix-org/matrix-js-sdk/pull/524) + * Make crypto.decryptMessage return decryption results + [\#523](https://github.com/matrix-org/matrix-js-sdk/pull/523) + +Changes in [0.8.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.8.0) (2017-08-15) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.13...v0.8.0) + +BREAKING CHANGE +--------------- + +In order to support a move to a more scalable storage backend, we need to make +a number of the APIs related end-to-end encryption asynchronous. + +This release of the JS-SDK includes the following changes which will affect +applications which support end-to-end encryption: + +1. `MatrixClient` now provides a new (asynchronous) method, + `initCrypto`. Applications which support end-to-end encryption must call + this method (and wait for it to complete) before calling `startClient`, to + give the crypto layer a chance to initialise. + +2. The following APIs have been changed to return promises: + + * `MatrixClient.getStoredDevicesForUser` + * `MatrixClient.getStoredDevice` + * `MatrixClient.setDeviceVerified` + * `MatrixClient.setDeviceBlocked` + * `MatrixClient.setDeviceKnown` + * `MatrixClient.getEventSenderDeviceInfo` + * `MatrixClient.isEventSenderVerified` + * `MatrixClient.importRoomKeys` + + Applications using the results of any of the above methods will need to be + updated to wait for the result of the promise. + + +3. `MatrixClient.listDeviceKeys` has been removed altogether. It's been + deprecated for some time. Applications using it should instead be changed to + use `MatrixClient.getStoredDevices`, which is similar but returns its results + in a slightly different format. + + + * Make bits of `olmlib` asynchronous + [\#521](https://github.com/matrix-org/matrix-js-sdk/pull/521) + * Make some of DeviceList asynchronous + [\#520](https://github.com/matrix-org/matrix-js-sdk/pull/520) + * Make methods in crypto/algorithms async + [\#519](https://github.com/matrix-org/matrix-js-sdk/pull/519) + * Avoid sending unencrypted messages in e2e room + [\#518](https://github.com/matrix-org/matrix-js-sdk/pull/518) + * Make tests wait for syncs to happen + [\#517](https://github.com/matrix-org/matrix-js-sdk/pull/517) + * Make a load of methods in the 'Crypto' module asynchronous + [\#510](https://github.com/matrix-org/matrix-js-sdk/pull/510) + * Set `rawDisplayName` to `userId` if membership has `displayname=null` + [\#515](https://github.com/matrix-org/matrix-js-sdk/pull/515) + * Refactor handling of crypto events for async + [\#508](https://github.com/matrix-org/matrix-js-sdk/pull/508) + * Let event decryption be asynchronous + [\#509](https://github.com/matrix-org/matrix-js-sdk/pull/509) + * Transform `async` functions to bluebird promises + [\#511](https://github.com/matrix-org/matrix-js-sdk/pull/511) + * Add more group APIs + [\#512](https://github.com/matrix-org/matrix-js-sdk/pull/512) + * Retrying test: wait for localEchoUpdated event + [\#507](https://github.com/matrix-org/matrix-js-sdk/pull/507) + * Fix member events breaking on timeline reset, 2 + [\#504](https://github.com/matrix-org/matrix-js-sdk/pull/504) + * Make bits of the js-sdk api asynchronous + [\#503](https://github.com/matrix-org/matrix-js-sdk/pull/503) + * Yet more js-sdk test deflakification + [\#499](https://github.com/matrix-org/matrix-js-sdk/pull/499) + * Fix racy 'matrixclient retrying' test + [\#497](https://github.com/matrix-org/matrix-js-sdk/pull/497) + * Fix spamming of key-share-requests + [\#495](https://github.com/matrix-org/matrix-js-sdk/pull/495) + * Add progress handler to `uploadContent` + [\#500](https://github.com/matrix-org/matrix-js-sdk/pull/500) + * Switch matrix-js-sdk to bluebird + [\#490](https://github.com/matrix-org/matrix-js-sdk/pull/490) + * Fix some more flakey tests + [\#492](https://github.com/matrix-org/matrix-js-sdk/pull/492) + * make the npm test script windows-friendly + [\#489](https://github.com/matrix-org/matrix-js-sdk/pull/489) + * Fix a bunch of races in the tests + [\#488](https://github.com/matrix-org/matrix-js-sdk/pull/488) + * Fix early return in MatrixClient.setGuestAccess + [\#487](https://github.com/matrix-org/matrix-js-sdk/pull/487) + * Remove testUtils.failTest + [\#486](https://github.com/matrix-org/matrix-js-sdk/pull/486) + * Add test:watch script + [\#485](https://github.com/matrix-org/matrix-js-sdk/pull/485) + * Make it possible to use async/await + [\#484](https://github.com/matrix-org/matrix-js-sdk/pull/484) + * Remove m.new_device support + [\#483](https://github.com/matrix-org/matrix-js-sdk/pull/483) + * Use access-token in header + [\#478](https://github.com/matrix-org/matrix-js-sdk/pull/478) + * Sanity-check response from /thirdparty/protocols + [\#482](https://github.com/matrix-org/matrix-js-sdk/pull/482) + * Avoid parsing plain-text errors as JSON + [\#479](https://github.com/matrix-org/matrix-js-sdk/pull/479) + * Use external mock-request + [\#481](https://github.com/matrix-org/matrix-js-sdk/pull/481) + * Fix some races in the tests + [\#480](https://github.com/matrix-org/matrix-js-sdk/pull/480) + * Fall back to MemoryCryptoStore if indexeddb fails + [\#475](https://github.com/matrix-org/matrix-js-sdk/pull/475) + * Fix load failure in firefox when indexedDB is disabled + [\#474](https://github.com/matrix-org/matrix-js-sdk/pull/474) + * Fix a race in a test + [\#471](https://github.com/matrix-org/matrix-js-sdk/pull/471) + * Avoid throwing an unhandled error when the indexeddb is deleted + [\#470](https://github.com/matrix-org/matrix-js-sdk/pull/470) + * fix jsdoc + [\#469](https://github.com/matrix-org/matrix-js-sdk/pull/469) + * Handle m.forwarded_room_key events + [\#468](https://github.com/matrix-org/matrix-js-sdk/pull/468) + * Improve error reporting from indexeddbstore.clearDatabase + [\#466](https://github.com/matrix-org/matrix-js-sdk/pull/466) + * Implement sharing of megolm keys + [\#454](https://github.com/matrix-org/matrix-js-sdk/pull/454) + * Process received room key requests + [\#449](https://github.com/matrix-org/matrix-js-sdk/pull/449) + * Send m.room_key_request events when we fail to decrypt an event + [\#448](https://github.com/matrix-org/matrix-js-sdk/pull/448) + +Changes in [0.7.13](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.13) (2017-06-22) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.12...v0.7.13) + + * Fix failure on Tor browser + [\#473](https://github.com/matrix-org/matrix-js-sdk/pull/473) + * Fix issues with firefox private browsing + [\#472](https://github.com/matrix-org/matrix-js-sdk/pull/472) + +Changes in [0.7.12](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.12) (2017-06-19) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.12-rc.1...v0.7.12) + + * No changes + + +Changes in [0.7.12-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.12-rc.1) (2017-06-15) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.11...v0.7.12-rc.1) + + * allow setting iceTransportPolicy to relay through forceTURN option + [\#462](https://github.com/matrix-org/matrix-js-sdk/pull/462) + +Changes in [0.7.11](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.11) (2017-06-12) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.11-rc.1...v0.7.11) + + * Add a bunch of logging around sending messages + [\#460](https://github.com/matrix-org/matrix-js-sdk/pull/460) + +Changes in [0.7.11-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.11-rc.1) (2017-06-09) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.10...v0.7.11-rc.1) + + * Make TimelineWindow.load resolve quicker if we have the events + [\#458](https://github.com/matrix-org/matrix-js-sdk/pull/458) + * Stop peeking when a matrix client is stopped + [\#451](https://github.com/matrix-org/matrix-js-sdk/pull/451) + * Update README: Clarify how to install libolm + [\#450](https://github.com/matrix-org/matrix-js-sdk/pull/450) + +Changes in [0.7.10](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.10) (2017-06-02) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.9...v0.7.10) + + * BREAKING CHANGE: The SDK no longer ``require``s ``olm`` - instead it expects + libolm to be provided as an ``Olm`` global. This will only affect + applications which use end-to-end encryption. See the + [README](README.md#end-to-end-encryption-support) for details. + + * indexeddb-crypto-store: fix db deletion + [\#447](https://github.com/matrix-org/matrix-js-sdk/pull/447) + * Load Olm from the global rather than requiring it. + [\#446](https://github.com/matrix-org/matrix-js-sdk/pull/446) + +Changes in [0.7.9](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.9) (2017-06-01) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.8...v0.7.9) + + * Initial framework for indexeddb-backed crypto store + [\#445](https://github.com/matrix-org/matrix-js-sdk/pull/445) + * Factor out reEmit to a common module + [\#444](https://github.com/matrix-org/matrix-js-sdk/pull/444) + * crypto/algorithms/base.js: Convert to es6 + [\#443](https://github.com/matrix-org/matrix-js-sdk/pull/443) + * maySendRedactionForEvent for userId + [\#435](https://github.com/matrix-org/matrix-js-sdk/pull/435) + * MatrixClient: add getUserId() + [\#441](https://github.com/matrix-org/matrix-js-sdk/pull/441) + * Run jsdoc on a custom babeling of the source + [\#442](https://github.com/matrix-org/matrix-js-sdk/pull/442) + * Add in a public api getStoredDevice allowing clients to get a specific + device + [\#439](https://github.com/matrix-org/matrix-js-sdk/pull/439) + +Changes in [0.7.8](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.8) (2017-05-22) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.8-rc.1...v0.7.8) + + * No changes + + +Changes in [0.7.8-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.8-rc.1) (2017-05-19) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.7...v0.7.8-rc.1) + + * Attempt to rework the release-tarball-signing stuff + [\#438](https://github.com/matrix-org/matrix-js-sdk/pull/438) + * ability to specify webrtc audio/video inputs for the lib to request + [\#427](https://github.com/matrix-org/matrix-js-sdk/pull/427) + * make screen sharing call FF friendly :D + [\#434](https://github.com/matrix-org/matrix-js-sdk/pull/434) + * Fix race in device list updates + [\#431](https://github.com/matrix-org/matrix-js-sdk/pull/431) + * WebRTC: Support recvonly for video for those without a webcam + [\#424](https://github.com/matrix-org/matrix-js-sdk/pull/424) + * Update istanbul to remove minimatch DoS Warning + [\#422](https://github.com/matrix-org/matrix-js-sdk/pull/422) + * webrtc/call: Make it much less likely that callIds collide locally + [\#423](https://github.com/matrix-org/matrix-js-sdk/pull/423) + * Automatically complete dummy auth + [\#420](https://github.com/matrix-org/matrix-js-sdk/pull/420) + * Don't leave the gh-pages branch checked out + [\#418](https://github.com/matrix-org/matrix-js-sdk/pull/418) + +Changes in [0.7.7](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.7) (2017-04-25) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.7-rc.1...v0.7.7) + + * No changes + + +Changes in [0.7.7-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.7-rc.1) (2017-04-21) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.6...v0.7.7-rc.1) + + * Automatically complete dummy auth + [\#420](https://github.com/matrix-org/matrix-js-sdk/pull/420) + + +Changes in [0.7.6](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.6) (2017-04-12) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.6-rc.2...v0.7.6) + + * No changes + +Changes in [0.7.6-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.6-rc.2) (2017-04-10) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.6-rc.1...v0.7.6-rc.2) + + * Add feature detection for webworkers + [\#416](https://github.com/matrix-org/matrix-js-sdk/pull/416) + * Fix release script + [\#415](https://github.com/matrix-org/matrix-js-sdk/pull/415) + +Changes in [0.7.6-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.6-rc.1) (2017-04-07) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.5...v0.7.6-rc.1) + + * Make indexeddb save after the first sync + [\#414](https://github.com/matrix-org/matrix-js-sdk/pull/414) + * Make indexeddb startup faster + [\#413](https://github.com/matrix-org/matrix-js-sdk/pull/413) + * Add ability to do indexeddb sync work in webworker + [\#412](https://github.com/matrix-org/matrix-js-sdk/pull/412) + * Move more functionality to the indexeddb backend + [\#409](https://github.com/matrix-org/matrix-js-sdk/pull/409) + * Indicate syncState ERROR after many failed /syncs + [\#410](https://github.com/matrix-org/matrix-js-sdk/pull/410) + * Further reorganising of indexeddb sync code + [\#407](https://github.com/matrix-org/matrix-js-sdk/pull/407) + * Change interface of IndexedDBStore: hide internals + [\#406](https://github.com/matrix-org/matrix-js-sdk/pull/406) + * Don't be SYNCING until updating from the server + [\#405](https://github.com/matrix-org/matrix-js-sdk/pull/405) + * Don't log the entire /sync response + [\#403](https://github.com/matrix-org/matrix-js-sdk/pull/403) + * webrtc/call: Assign MediaStream to video element srcObject + [\#402](https://github.com/matrix-org/matrix-js-sdk/pull/402) + * Fix undefined reference in http-api + [\#400](https://github.com/matrix-org/matrix-js-sdk/pull/400) + * Add copyright header to event-timeline.js + [\#382](https://github.com/matrix-org/matrix-js-sdk/pull/382) + * client: fix docs for user-scoped account_data events + [\#397](https://github.com/matrix-org/matrix-js-sdk/pull/397) + * Add a CONTRIBUTING for js-sdk + [\#399](https://github.com/matrix-org/matrix-js-sdk/pull/399) + * Fix leaking room state objects on limited sync responses + [\#395](https://github.com/matrix-org/matrix-js-sdk/pull/395) + * Extend 'ignoreFailure' to be 'background' + [\#396](https://github.com/matrix-org/matrix-js-sdk/pull/396) + * Add x_show_msisdn parameter to register calls + [\#388](https://github.com/matrix-org/matrix-js-sdk/pull/388) + * Update event redaction to keep sender and origin_server_ts + [\#394](https://github.com/matrix-org/matrix-js-sdk/pull/394) + * Handle 'limited' timeline responses in the SyncAccumulator + [\#393](https://github.com/matrix-org/matrix-js-sdk/pull/393) + * Give a better error message if the HS doesn't support msisdn registeration + [\#391](https://github.com/matrix-org/matrix-js-sdk/pull/391) + * Add getEmailSid + [\#383](https://github.com/matrix-org/matrix-js-sdk/pull/383) + * Add m.login.email.identity support to UI auth + [\#380](https://github.com/matrix-org/matrix-js-sdk/pull/380) + * src/client.js: Fix incorrect roomId reference in VoIP glare code + [\#381](https://github.com/matrix-org/matrix-js-sdk/pull/381) + * add .editorconfig + [\#379](https://github.com/matrix-org/matrix-js-sdk/pull/379) + * Store account data in the same way as room data + [\#377](https://github.com/matrix-org/matrix-js-sdk/pull/377) + * Upload one-time keys on /sync rather than a timer + [\#376](https://github.com/matrix-org/matrix-js-sdk/pull/376) + * Increase the WRITE_DELAY on database syncing + [\#374](https://github.com/matrix-org/matrix-js-sdk/pull/374) + * Make deleteAllData() return a Promise + [\#373](https://github.com/matrix-org/matrix-js-sdk/pull/373) + * Don't include banned users in the room name + [\#372](https://github.com/matrix-org/matrix-js-sdk/pull/372) + * Support IndexedDB as a backing store + [\#363](https://github.com/matrix-org/matrix-js-sdk/pull/363) + * Poll /sync with a short timeout while catching up + [\#370](https://github.com/matrix-org/matrix-js-sdk/pull/370) + * Make test coverage work again + [\#368](https://github.com/matrix-org/matrix-js-sdk/pull/368) + * Add docs to event + [\#367](https://github.com/matrix-org/matrix-js-sdk/pull/367) + * Keep the device-sync token more up-to-date + [\#366](https://github.com/matrix-org/matrix-js-sdk/pull/366) + * Fix race conditions in device list download + [\#365](https://github.com/matrix-org/matrix-js-sdk/pull/365) + * Fix the unban method + [\#364](https://github.com/matrix-org/matrix-js-sdk/pull/364) + * Spread out device verification work + [\#362](https://github.com/matrix-org/matrix-js-sdk/pull/362) + * Clean up/improve e2e logging + [\#361](https://github.com/matrix-org/matrix-js-sdk/pull/361) + * Fix decryption of events whose key arrives later + [\#360](https://github.com/matrix-org/matrix-js-sdk/pull/360) + * Invalidate device lists when encryption is enabled in a room + [\#359](https://github.com/matrix-org/matrix-js-sdk/pull/359) + * Switch from jasmine to mocha + expect + lolex + [\#358](https://github.com/matrix-org/matrix-js-sdk/pull/358) + * Install source-map-support in each test + [\#356](https://github.com/matrix-org/matrix-js-sdk/pull/356) + * searchMessageText: avoid setting keys=undefined + [\#357](https://github.com/matrix-org/matrix-js-sdk/pull/357) + * realtime-callbacks: pass `global` as `this` + [\#355](https://github.com/matrix-org/matrix-js-sdk/pull/355) + * Make the tests work without olm + [\#354](https://github.com/matrix-org/matrix-js-sdk/pull/354) + * Tests: Factor out TestClient and use it in crypto tests + [\#353](https://github.com/matrix-org/matrix-js-sdk/pull/353) + * Fix some lint + [\#352](https://github.com/matrix-org/matrix-js-sdk/pull/352) + * Make a sig for source tarballs when releasing + [\#351](https://github.com/matrix-org/matrix-js-sdk/pull/351) + * When doing a pre-release, don't bother merging to master and develop. + [\#350](https://github.com/matrix-org/matrix-js-sdk/pull/350) + +Changes in [0.7.5](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.5) (2017-02-04) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.5-rc.3...v0.7.5) + +No changes from 0.7.5-rc.3 + +Changes in [0.7.5-rc.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.5-rc.3) (2017-02-03) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.5-rc.2...v0.7.5-rc.3) + + * Include DeviceInfo in deviceVerificationChanged events + [a3cc8eb](https://github.com/matrix-org/matrix-js-sdk/commit/a3cc8eb1f6d165576a342596f638316721cb26b6) + * Fix device list update + [5fd7410](https://github.com/matrix-org/matrix-js-sdk/commit/5fd74109ffc56b73deb40c2604d84c38b8032c40) + + +Changes in [0.7.5-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.5-rc.2) (2017-02-03) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.5-rc.1...v0.7.5-rc.2) + + * Use the device change notifications interface + [\#348](https://github.com/matrix-org/matrix-js-sdk/pull/348) + * Rewrite the device key query logic + [\#347](https://github.com/matrix-org/matrix-js-sdk/pull/347) + +Changes in [0.7.5-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.5-rc.1) (2017-02-03) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.4...v0.7.5-rc.1) + + * Support for blacklisting unverified devices, both per-room and globally + [\#336](https://github.com/matrix-org/matrix-js-sdk/pull/336) + * track errors when events can't be sent + [\#349](https://github.com/matrix-org/matrix-js-sdk/pull/349) + * Factor out device list management + [\#346](https://github.com/matrix-org/matrix-js-sdk/pull/346) + * Support for warning users when unknown devices show up + [\#335](https://github.com/matrix-org/matrix-js-sdk/pull/335) + * Enable sourcemaps in browserified distro + [\#345](https://github.com/matrix-org/matrix-js-sdk/pull/345) + * Record all e2e room settings in localstorage + [\#344](https://github.com/matrix-org/matrix-js-sdk/pull/344) + * Make Olm work with browserified js-sdk + [\#340](https://github.com/matrix-org/matrix-js-sdk/pull/340) + * Make browserify a dev dependency + [\#339](https://github.com/matrix-org/matrix-js-sdk/pull/339) + * Allow single line brace-style + [\#338](https://github.com/matrix-org/matrix-js-sdk/pull/338) + * Turn on comma-dangle for function calls + [\#333](https://github.com/matrix-org/matrix-js-sdk/pull/333) + * Add prefer-const + [\#331](https://github.com/matrix-org/matrix-js-sdk/pull/331) + * Support for importing and exporting megolm sessions + [\#326](https://github.com/matrix-org/matrix-js-sdk/pull/326) + * Fix linting on all tests + [\#329](https://github.com/matrix-org/matrix-js-sdk/pull/329) + * Fix ESLint warnings and errors + [\#325](https://github.com/matrix-org/matrix-js-sdk/pull/325) + * BREAKING CHANGE: Remove WebStorageStore + [\#324](https://github.com/matrix-org/matrix-js-sdk/pull/324) + +Changes in [0.7.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.4) (2017-01-16) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.4-rc.1...v0.7.4) + + * Fix non-conference calling + +Changes in [0.7.4-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.4-rc.1) (2017-01-13) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.3...v0.7.4-rc.1) + + * Remove babel-polyfill + [\#321](https://github.com/matrix-org/matrix-js-sdk/pull/321) + * Update build process for ES6 + [\#320](https://github.com/matrix-org/matrix-js-sdk/pull/320) + * 'babel' is not a babel package anymore + [\#319](https://github.com/matrix-org/matrix-js-sdk/pull/319) + * Add Babel for ES6 support + [\#318](https://github.com/matrix-org/matrix-js-sdk/pull/318) + * Move screen sharing check/error + [\#317](https://github.com/matrix-org/matrix-js-sdk/pull/317) + * release.sh: Bail early if there are uncommitted changes + [\#316](https://github.com/matrix-org/matrix-js-sdk/pull/316) + +Changes in [0.7.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.3) (2017-01-04) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.2...v0.7.3) + + * User presence list feature + [\#310](https://github.com/matrix-org/matrix-js-sdk/pull/310) + * Allow clients the ability to set a default local timeout + [\#313](https://github.com/matrix-org/matrix-js-sdk/pull/313) + * Add API to delete threepid + [\#312](https://github.com/matrix-org/matrix-js-sdk/pull/312) + +Changes in [0.7.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.2) (2016-12-15) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.1...v0.7.2) + + * Bump to Olm 2.0 + [\#309](https://github.com/matrix-org/matrix-js-sdk/pull/309) + * Sanity check payload length before encrypting + [\#307](https://github.com/matrix-org/matrix-js-sdk/pull/307) + * Remove dead _sendPingToDevice function + [\#308](https://github.com/matrix-org/matrix-js-sdk/pull/308) + * Add setRoomDirectoryVisibilityAppService + [\#306](https://github.com/matrix-org/matrix-js-sdk/pull/306) + * Update release script to do signed releases + [\#305](https://github.com/matrix-org/matrix-js-sdk/pull/305) + * e2e: Wait for pending device lists + [\#304](https://github.com/matrix-org/matrix-js-sdk/pull/304) + * Start a new megolm session when devices are blacklisted + [\#303](https://github.com/matrix-org/matrix-js-sdk/pull/303) + * E2E: Download our own devicelist on startup + [\#302](https://github.com/matrix-org/matrix-js-sdk/pull/302) + +Changes in [0.7.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.1) (2016-12-09) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.1-rc.1...v0.7.1) + +No changes + + +Changes in [0.7.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.1-rc.1) (2016-12-05) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.0...v0.7.1-rc.1) + + * Avoid NPE when no sessionStore is given + [\#300](https://github.com/matrix-org/matrix-js-sdk/pull/300) + * Improve decryption error messages + [\#299](https://github.com/matrix-org/matrix-js-sdk/pull/299) + * Revert "Use native Array.isArray when available." + [\#283](https://github.com/matrix-org/matrix-js-sdk/pull/283) + * Use native Array.isArray when available. + [\#282](https://github.com/matrix-org/matrix-js-sdk/pull/282) + +Changes in [0.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.0) (2016-11-18) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.6.4...v0.7.0) + + * Avoid a packetstorm of device queries on startup + [\#297](https://github.com/matrix-org/matrix-js-sdk/pull/297) + * E2E: Check devices to share keys with on each send + [\#295](https://github.com/matrix-org/matrix-js-sdk/pull/295) + * Apply unknown-keyshare mitigations + [\#296](https://github.com/matrix-org/matrix-js-sdk/pull/296) + * distinguish unknown users from deviceless users + [\#294](https://github.com/matrix-org/matrix-js-sdk/pull/294) + * Allow starting client with initialSyncLimit = 0 + [\#293](https://github.com/matrix-org/matrix-js-sdk/pull/293) + * Make timeline-window _unpaginate public and rename to unpaginate + [\#289](https://github.com/matrix-org/matrix-js-sdk/pull/289) + * Send a STOPPED sync updated after call to stopClient + [\#286](https://github.com/matrix-org/matrix-js-sdk/pull/286) + * Fix bug in verifying megolm event senders + [\#292](https://github.com/matrix-org/matrix-js-sdk/pull/292) + * Handle decryption of events after they arrive + [\#288](https://github.com/matrix-org/matrix-js-sdk/pull/288) + * Fix examples. + [\#287](https://github.com/matrix-org/matrix-js-sdk/pull/287) + * Add a travis.yml + [\#278](https://github.com/matrix-org/matrix-js-sdk/pull/278) + * Encrypt all events, including 'm.call.*' + [\#277](https://github.com/matrix-org/matrix-js-sdk/pull/277) + * Ignore reshares of known megolm sessions + [\#276](https://github.com/matrix-org/matrix-js-sdk/pull/276) + * Log to the console on unknown session + [\#274](https://github.com/matrix-org/matrix-js-sdk/pull/274) + * Make it easier for SDK users to wrap prevailing the 'request' function + [\#273](https://github.com/matrix-org/matrix-js-sdk/pull/273) + +Changes in [0.6.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.6.4) (2016-11-04) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.6.4-rc.2...v0.6.4) + + * Change release script to pass version by environment variable + + +Changes in [0.6.4-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.6.4-rc.2) (2016-11-02) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.6.4-rc.1...v0.6.4-rc.2) + + * Add getRoomTags method to client + [\#236](https://github.com/matrix-org/matrix-js-sdk/pull/236) + +Changes in [0.6.4-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.6.4-rc.1) (2016-11-02) +========================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.6.3...v0.6.4-rc.1) + +Breaking Changes +---------------- + * Bundled version of the JS SDK are no longer versioned along with + source files in the dist/ directory. As of this release, they + will be included in the release tarball, but not the source + repository. + +Other Changes +------------- + * More fixes to the release script + [\#272](https://github.com/matrix-org/matrix-js-sdk/pull/272) + * Update the release process to use github releases + [\#271](https://github.com/matrix-org/matrix-js-sdk/pull/271) + * Don't package the world when we release + [\#270](https://github.com/matrix-org/matrix-js-sdk/pull/270) + * Add ability to set a filter prior to the first /sync + [\#269](https://github.com/matrix-org/matrix-js-sdk/pull/269) + * Sign one-time keys, and verify their signatures + [\#243](https://github.com/matrix-org/matrix-js-sdk/pull/243) + * Check for duplicate message indexes for group messages + [\#241](https://github.com/matrix-org/matrix-js-sdk/pull/241) + * Rotate megolm sessions + [\#240](https://github.com/matrix-org/matrix-js-sdk/pull/240) + * Check recipient and sender in Olm messages + [\#239](https://github.com/matrix-org/matrix-js-sdk/pull/239) + * Consistency checks for E2E device downloads + [\#237](https://github.com/matrix-org/matrix-js-sdk/pull/237) + * Support User-Interactive auth for delete device + [\#235](https://github.com/matrix-org/matrix-js-sdk/pull/235) + * Utility to help with interactive auth + [\#234](https://github.com/matrix-org/matrix-js-sdk/pull/234) + * Fix sync breaking when an invalid filterId is in localStorage + [\#228](https://github.com/matrix-org/matrix-js-sdk/pull/228) + +Changes in [0.6.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.6.3) (2016-10-12) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.6.2...v0.6.3) + +Breaking Changes +---------------- + * Add a 'RECONNECTING' state to the sync states. This is an additional state + between 'SYNCING' and 'ERROR', so most clients should not notice. + +Other Changes +---------------- + * Fix params getting replaced on register calls + [\#233](https://github.com/matrix-org/matrix-js-sdk/pull/233) + * Fix potential 30s delay on reconnect + [\#232](https://github.com/matrix-org/matrix-js-sdk/pull/232) + * uploadContent: Attempt some consistency between browser and node + [\#230](https://github.com/matrix-org/matrix-js-sdk/pull/230) + * Fix error handling on uploadContent + [\#229](https://github.com/matrix-org/matrix-js-sdk/pull/229) + * Fix uploadContent for node.js + [\#226](https://github.com/matrix-org/matrix-js-sdk/pull/226) + * Don't emit ERROR until a keepalive poke fails + [\#223](https://github.com/matrix-org/matrix-js-sdk/pull/223) + * Function to get the fallback url for interactive auth + [\#224](https://github.com/matrix-org/matrix-js-sdk/pull/224) + * Revert "Handle the first /sync failure differently." + [\#222](https://github.com/matrix-org/matrix-js-sdk/pull/222) + +Changes in [0.6.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.6.2) (2016-10-05) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.6.1...v0.6.2) + + * Check dependencies aren't on develop in release.sh + [\#221](https://github.com/matrix-org/matrix-js-sdk/pull/221) + * Fix checkTurnServers leak on logout + [\#220](https://github.com/matrix-org/matrix-js-sdk/pull/220) + * Fix leak of file upload objects + [\#219](https://github.com/matrix-org/matrix-js-sdk/pull/219) + * crypto: remove duplicate code + [\#218](https://github.com/matrix-org/matrix-js-sdk/pull/218) + * Add API for 3rd party location lookup + [\#217](https://github.com/matrix-org/matrix-js-sdk/pull/217) + * Handle the first /sync failure differently. + [\#216](https://github.com/matrix-org/matrix-js-sdk/pull/216) + +Changes in [0.6.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.6.1) (2016-09-21) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.6.0...v0.6.1) + + * Fix the ed25519 key checking + [\#215](https://github.com/matrix-org/matrix-js-sdk/pull/215) + * Add MatrixClient.getEventSenderDeviceInfo() + [\#214](https://github.com/matrix-org/matrix-js-sdk/pull/214) + +Changes in [0.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.6.0) (2016-09-21) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.5.6...v0.6.0) + + * Pull user device list on join + [\#212](https://github.com/matrix-org/matrix-js-sdk/pull/212) + * Fix sending of oh_hais on bad sessions + [\#213](https://github.com/matrix-org/matrix-js-sdk/pull/213) + * Support /publicRooms pagination + [\#211](https://github.com/matrix-org/matrix-js-sdk/pull/211) + * Update the olm library version to 1.3.0 + [\#205](https://github.com/matrix-org/matrix-js-sdk/pull/205) + * Comment what the logic in uploadKeys does + [\#209](https://github.com/matrix-org/matrix-js-sdk/pull/209) + * Include keysProved and keysClaimed in the local echo for events we send. + [\#210](https://github.com/matrix-org/matrix-js-sdk/pull/210) + * Check if we need to upload new one-time keys every 10 minutes + [\#208](https://github.com/matrix-org/matrix-js-sdk/pull/208) + * Reset oneTimeKey to null on each loop iteration. + [\#207](https://github.com/matrix-org/matrix-js-sdk/pull/207) + * Add getKeysProved and getKeysClaimed methods to MatrixEvent. + [\#206](https://github.com/matrix-org/matrix-js-sdk/pull/206) + * Send a 'm.new_device' when we get a message for an unknown group session + [\#204](https://github.com/matrix-org/matrix-js-sdk/pull/204) + * Introduce EventTimelineSet, filtered timelines and global notif timeline. + [\#196](https://github.com/matrix-org/matrix-js-sdk/pull/196) + * Wrap the crypto event handlers in try/catch blocks + [\#203](https://github.com/matrix-org/matrix-js-sdk/pull/203) + * Show warnings on to-device decryption fail + [\#202](https://github.com/matrix-org/matrix-js-sdk/pull/202) + * s/Displayname/DisplayName/ + [\#201](https://github.com/matrix-org/matrix-js-sdk/pull/201) + * OH HAI + [\#200](https://github.com/matrix-org/matrix-js-sdk/pull/200) + * Share the current ratchet with new members + [\#199](https://github.com/matrix-org/matrix-js-sdk/pull/199) + * Move crypto bits into a subdirectory + [\#198](https://github.com/matrix-org/matrix-js-sdk/pull/198) + * Refactor event handling in Crypto + [\#197](https://github.com/matrix-org/matrix-js-sdk/pull/197) + * Don't create Olm sessions proactively + [\#195](https://github.com/matrix-org/matrix-js-sdk/pull/195) + * Use to-device events for key sharing + [\#194](https://github.com/matrix-org/matrix-js-sdk/pull/194) + * README: callbacks deprecated + [\#193](https://github.com/matrix-org/matrix-js-sdk/pull/193) + * Fix sender verification for megolm messages + [\#192](https://github.com/matrix-org/matrix-js-sdk/pull/192) + * Use `ciphertext` instead of `body` in megolm events + [\#191](https://github.com/matrix-org/matrix-js-sdk/pull/191) + * Add debug methods to get the state of OlmSessions + [\#189](https://github.com/matrix-org/matrix-js-sdk/pull/189) + * MatrixClient.getStoredDevicesForUser + [\#190](https://github.com/matrix-org/matrix-js-sdk/pull/190) + * Olm-related cleanups + [\#188](https://github.com/matrix-org/matrix-js-sdk/pull/188) + * Update to fixed olmlib + [\#187](https://github.com/matrix-org/matrix-js-sdk/pull/187) + * always play audio out of the remoteAudioElement if it exists. + [\#186](https://github.com/matrix-org/matrix-js-sdk/pull/186) + * Fix exceptions where HTMLMediaElement loads and plays race + [\#185](https://github.com/matrix-org/matrix-js-sdk/pull/185) + * Reset megolm session when people join/leave the room + [\#183](https://github.com/matrix-org/matrix-js-sdk/pull/183) + * Fix exceptions when dealing with redactions + [\#184](https://github.com/matrix-org/matrix-js-sdk/pull/184) + +Changes in [0.5.6](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.5.6) (2016-08-28) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.5.5...v0.5.6) + + * Put all of the megolm keys in one room message + [\#182](https://github.com/matrix-org/matrix-js-sdk/pull/182) + * Reinstate device blocking for simple Olm + [\#181](https://github.com/matrix-org/matrix-js-sdk/pull/181) + * support for unpacking megolm keys + [\#180](https://github.com/matrix-org/matrix-js-sdk/pull/180) + * Send out megolm keys when we start a megolm session + [\#179](https://github.com/matrix-org/matrix-js-sdk/pull/179) + * Change the result structure for ensureOlmSessionsForUsers + [\#178](https://github.com/matrix-org/matrix-js-sdk/pull/178) + * Factor out a function for doing olm encryption + [\#177](https://github.com/matrix-org/matrix-js-sdk/pull/177) + * Move DeviceInfo and DeviceVerification to separate module + [\#175](https://github.com/matrix-org/matrix-js-sdk/pull/175) + * Make encryption asynchronous + [\#176](https://github.com/matrix-org/matrix-js-sdk/pull/176) + * Added ability to set and get status_msg for presence. + [\#167](https://github.com/matrix-org/matrix-js-sdk/pull/167) + * Megolm: don't dereference nullable object + [\#174](https://github.com/matrix-org/matrix-js-sdk/pull/174) + * Implement megolm encryption/decryption + [\#173](https://github.com/matrix-org/matrix-js-sdk/pull/173) + * Update our push rules when they come down stream + [\#170](https://github.com/matrix-org/matrix-js-sdk/pull/170) + * Factor Olm encryption/decryption out to new classes + [\#172](https://github.com/matrix-org/matrix-js-sdk/pull/172) + * Make DeviceInfo more useful, and refactor crypto methods to use it + [\#171](https://github.com/matrix-org/matrix-js-sdk/pull/171) + * Move login and register methods into base-apis + [\#169](https://github.com/matrix-org/matrix-js-sdk/pull/169) + * Remove defaultDeviceDisplayName from MatrixClient options + [\#168](https://github.com/matrix-org/matrix-js-sdk/pull/168) + +Changes in [0.5.5](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.5.5) (2016-08-11) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.5.4...v0.5.5) + + * Add room.getAliases() and room.getCanonicalAlias + * Add API calls `/register/email/requestToken`, `/account/password/email/requestToken` and `/account/3pid/email/requestToken` + * Add `User.currentlyActive` and `User.lastPresenceTs` events for changes in fields on the User object + * Add `logout` and `deactivateAccount` + + * Make sure we actually stop the sync loop on logout + [\#166](https://github.com/matrix-org/matrix-js-sdk/pull/166) + * Zero is a valid power level + [\#164](https://github.com/matrix-org/matrix-js-sdk/pull/164) + * Verify e2e keys on download + [\#163](https://github.com/matrix-org/matrix-js-sdk/pull/163) + * Factor crypto stuff out of MatrixClient + [\#162](https://github.com/matrix-org/matrix-js-sdk/pull/162) + * Refactor device key upload + [\#161](https://github.com/matrix-org/matrix-js-sdk/pull/161) + * Wrappers for devices API + [\#158](https://github.com/matrix-org/matrix-js-sdk/pull/158) + * Add deactivate account function + [\#160](https://github.com/matrix-org/matrix-js-sdk/pull/160) + * client.listDeviceKeys: Expose device display name + [\#159](https://github.com/matrix-org/matrix-js-sdk/pull/159) + * Add `logout` + [\#157](https://github.com/matrix-org/matrix-js-sdk/pull/157) + * Fix email registration + [\#156](https://github.com/matrix-org/matrix-js-sdk/pull/156) + * Factor out MatrixClient methods to MatrixBaseApis + [\#155](https://github.com/matrix-org/matrix-js-sdk/pull/155) + * Fix some broken tests + [\#154](https://github.com/matrix-org/matrix-js-sdk/pull/154) + * make jenkins fail the build if the tests fail + [\#153](https://github.com/matrix-org/matrix-js-sdk/pull/153) + * deviceId-related fixes + [\#152](https://github.com/matrix-org/matrix-js-sdk/pull/152) + * /login, /register: Add device_id and initial_device_display_name + [\#151](https://github.com/matrix-org/matrix-js-sdk/pull/151) + * Support global account_data + [\#150](https://github.com/matrix-org/matrix-js-sdk/pull/150) + * Add more events to User + [\#149](https://github.com/matrix-org/matrix-js-sdk/pull/149) + * Add API calls for other requestToken endpoints + [\#148](https://github.com/matrix-org/matrix-js-sdk/pull/148) + * Add register-specific request token endpoint + [\#147](https://github.com/matrix-org/matrix-js-sdk/pull/147) + * Set a valid SPDX license identifier in package.json + [\#139](https://github.com/matrix-org/matrix-js-sdk/pull/139) + * Configure encryption on m.room.encryption events + [\#145](https://github.com/matrix-org/matrix-js-sdk/pull/145) + * Implement device blocking + [\#146](https://github.com/matrix-org/matrix-js-sdk/pull/146) + * Clearer doc for setRoomDirectoryVisibility + [\#144](https://github.com/matrix-org/matrix-js-sdk/pull/144) + * crypto: use memberlist to derive recipient list + [\#143](https://github.com/matrix-org/matrix-js-sdk/pull/143) + * Support for marking devices as unverified + [\#142](https://github.com/matrix-org/matrix-js-sdk/pull/142) + * Add Olm as an optionalDependency + [\#141](https://github.com/matrix-org/matrix-js-sdk/pull/141) + * Add room.getAliases() and room.getCanonicalAlias() + [\#140](https://github.com/matrix-org/matrix-js-sdk/pull/140) + * Change how MatrixEvent manages encrypted events + [\#138](https://github.com/matrix-org/matrix-js-sdk/pull/138) + * Catch exceptions when encrypting events + [\#137](https://github.com/matrix-org/matrix-js-sdk/pull/137) + * Support for marking devices as verified + [\#136](https://github.com/matrix-org/matrix-js-sdk/pull/136) + * Various matrix-client refactorings and fixes + [\#134](https://github.com/matrix-org/matrix-js-sdk/pull/134) + +Changes in [0.5.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.5.4) (2016-06-02) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.5.3...v0.5.4) + + * Correct fix for https://github.com/vector-im/vector-web/issues/1039 + * Make release.sh work on OSX + + +Changes in [0.5.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.5.3) (2016-06-02) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.5.2...v0.5.3) + + * Add support for the openid interface + [\#133](https://github.com/matrix-org/matrix-js-sdk/pull/133) + * Bugfix for HTTP upload content when running on node + [\#129](https://github.com/matrix-org/matrix-js-sdk/pull/129) + * Ignore missing profile (displayname and avatar_url) fields on + presence events, rather than overwriting existing valid profile + data from membership events or elsewhere. + Fixes https://github.com/vector-im/vector-web/issues/1039 + +Changes in [0.5.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.5.2) (2016-04-19) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.5.1...v0.5.2) + + * Track the absolute time that presence events are received, so that the + relative lastActiveAgo value is meaningful. + [\#128](https://github.com/matrix-org/matrix-js-sdk/pull/128) + * Refactor the addition of events to rooms + [\#127](https://github.com/matrix-org/matrix-js-sdk/pull/127) + * Clean up test shutdown + [\#126](https://github.com/matrix-org/matrix-js-sdk/pull/126) + * Add methods to get (and set) pushers + [\#125](https://github.com/matrix-org/matrix-js-sdk/pull/125) + * URL previewing support + [\#122](https://github.com/matrix-org/matrix-js-sdk/pull/122) + * Avoid paginating forever in private rooms + [\#124](https://github.com/matrix-org/matrix-js-sdk/pull/124) + * Fix a bug where we recreated sync filters + [\#123](https://github.com/matrix-org/matrix-js-sdk/pull/123) + * Implement HTTP timeouts in realtime + [\#121](https://github.com/matrix-org/matrix-js-sdk/pull/121) + +Changes in [0.5.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.5.1) (2016-03-30) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.5.0...v0.5.1) + + * Only count joined members for the member count in notifications. + [\#119](https://github.com/matrix-org/matrix-js-sdk/pull/119) + * Add maySendEvent to match maySendStateEvent + [\#118](https://github.com/matrix-org/matrix-js-sdk/pull/118) + +Changes in [0.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.5.0) (2016-03-22) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.4.2...v0.5.0) + +**BREAKING CHANGES**: + * `opts.pendingEventOrdering`==`end` is no longer supported in the arguments to + `MatrixClient.startClient()`. Instead we provide a `detached` option, which + puts pending events into a completely separate list in the Room, accessible + via `Room.getPendingEvents()`. + [\#111](https://github.com/matrix-org/matrix-js-sdk/pull/111) + +Other improvements: + * Log the stack when we get a sync error + [\#109](https://github.com/matrix-org/matrix-js-sdk/pull/109) + * Refactor transmitted-messages code + [\#110](https://github.com/matrix-org/matrix-js-sdk/pull/110) + * Add a method to the js sdk to look up 3pids on the ID server. + [\#113](https://github.com/matrix-org/matrix-js-sdk/pull/113) + * Support for cancelling pending events + [\#112](https://github.com/matrix-org/matrix-js-sdk/pull/112) + * API to stop peeking + [\#114](https://github.com/matrix-org/matrix-js-sdk/pull/114) + * update store user metadata based on membership events rather than presence + [\#116](https://github.com/matrix-org/matrix-js-sdk/pull/116) + * Include a counter in generated transaction IDs + [\#115](https://github.com/matrix-org/matrix-js-sdk/pull/115) + * get/setRoomVisibility API + [\#117](https://github.com/matrix-org/matrix-js-sdk/pull/117) + +Changes in [0.4.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.4.2) (2016-03-17) +================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.4.1...v0.4.2) + + * Try again if a pagination request gives us no new messages + [\#98](https://github.com/matrix-org/matrix-js-sdk/pull/98) + * Add a delay before we start polling the connectivity check endpoint + [\#99](https://github.com/matrix-org/matrix-js-sdk/pull/99) + * Clean up a codepath that was only used for crypto messages + [\#101](https://github.com/matrix-org/matrix-js-sdk/pull/101) + * Add maySendStateEvent method, ported from react-sdk (but fixed). + [\#94](https://github.com/matrix-org/matrix-js-sdk/pull/94) + * Add Session.logged_out event + [\#100](https://github.com/matrix-org/matrix-js-sdk/pull/100) + * make presence work when peeking. + [\#103](https://github.com/matrix-org/matrix-js-sdk/pull/103) + * Add RoomState.mayClientSendStateEvent() + [\#104](https://github.com/matrix-org/matrix-js-sdk/pull/104) + * Fix displaynames for member join events + [\#108](https://github.com/matrix-org/matrix-js-sdk/pull/108) + +Changes in 0.4.1 +================ + +Improvements: + * Check that `/sync` filters are correct before reusing them, and recreate + them if not (https://github.com/matrix-org/matrix-js-sdk/pull/85). + * Fire a `Room.timelineReset` event when a room's timeline is reset by a gappy + `/sync` (https://github.com/matrix-org/matrix-js-sdk/pull/87, + https://github.com/matrix-org/matrix-js-sdk/pull/93). + * Make `TimelineWindow.load()` faster in the simple case of loading the live + timeline (https://github.com/matrix-org/matrix-js-sdk/pull/88). + * Update room-name calculation code to use the name of the sender of the + invite when invited to a room + (https://github.com/matrix-org/matrix-js-sdk/pull/89). + * Don't reset the timeline when we join a room after peeking into it + (https://github.com/matrix-org/matrix-js-sdk/pull/91). + * Fire `Room.localEchoUpdated` events as local echoes progress through their + transmission process (https://github.com/matrix-org/matrix-js-sdk/pull/95, + https://github.com/matrix-org/matrix-js-sdk/pull/97). + * Avoid getting stuck in a pagination loop when the server sends us only + messages we've already seen + (https://github.com/matrix-org/matrix-js-sdk/pull/96). + +New methods: + * Add `MatrixClient.setPushRuleActions` to set the actions for a push + notification rule (https://github.com/matrix-org/matrix-js-sdk/pull/90) + * Add `RoomState.maySendStateEvent` which determines if a given user has + permission to send a state event + (https://github.com/matrix-org/matrix-js-sdk/pull/94) + +Changes in 0.4.0 +================ + +**BREAKING CHANGES**: + * `RoomMember.getAvatarUrl()` and `MatrixClient.mxcUrlToHttp()` now return the + empty string when given anything other than an mxc:// URL. This ensures that + clients never inadvertantly reference content directly, leaking information + to third party servers. The `allowDirectLinks` option is provided if the client + wants to allow such links. + * Add a 'bindEmail' option to register() + +Improvements: + * Support third party invites + * More appropriate naming for third party invite rooms + * Poll the 'versions' endpoint to re-establish connectivity + * Catch exceptions when syncing + * Room tag support + * Generate implicit read receipts + * Support CAS login + * Guest access support + * Never return non-mxc URLs by default + * Ability to cancel file uploads + * Use the Matrix C/S API v2 with r0 prefix + * Account data support + * Support non-contiguous event timelines + * Support new unread counts + * Local echo for read-receipts + + +New methods: + * Add method to fetch URLs not on the home or identity server + * Method to get the last receipt for a user + * Method to get all known users + * Method to delete an alias + + +Changes in 0.3.0 +================ + + * `MatrixClient.getAvatarUrlForMember` has been removed and replaced with + `RoomMember.getAvatarUrl`. Arguments remain the same except the homeserver + URL must now be supplied from `MatrixClient.getHomeserverUrl()`. + + ```javascript + // before + var url = client.getAvatarUrlForMember(member, width, height, resize, allowDefault) + // after + var url = member.getAvatarUrl(client.getHomeserverUrl(), width, height, resize, allowDefault) + ``` + * `MatrixClient.getAvatarUrlForRoom` has been removed and replaced with + `Room.getAvatarUrl`. Arguments remain the same except the homeserver + URL must now be supplied from `MatrixClient.getHomeserverUrl()`. + + ```javascript + // before + var url = client.getAvatarUrlForRoom(room, width, height, resize, allowDefault) + // after + var url = room.getAvatarUrl(client.getHomeserverUrl(), width, height, resize, allowDefault) + ``` + + * `s/Room.getMembersWithMemership/Room.getMembersWithMem`b`ership/g` + +New methods: + * Added support for sending receipts via + `MatrixClient.sendReceipt(event, receiptType, callback)` and + `MatrixClient.sendReadReceipt(event, callback)`. + * Added support for receiving receipts via + `Room.getReceiptsForEvent(event)` and `Room.getUsersReadUpTo(event)`. Receipts + can be directly added to a `Room` using `Room.addReceipt(event)` though the + `MatrixClient` does this for you. + * Added support for muting local video and audio via the new methods + `MatrixCall.setMicrophoneMuted()`, `MatrixCall.isMicrophoneMuted(muted)`, + `MatrixCall.isLocalVideoMuted()` and `Matrix.setLocalVideoMuted(muted)`. + * Added **experimental** support for screen-sharing in Chrome via + `MatrixCall.placeScreenSharingCall(remoteVideoElement, localVideoElement)`. + * Added ability to perform server-side searches using + `MatrixClient.searchMessageText(opts)` and `MatrixClient.search(opts)`. + +Improvements: + * Improve the performance of initial sync processing from `O(n^2)` to `O(n)`. + * `Room.name` will now take into account `m.room.canonical_alias` events. + * `MatrixClient.startClient` now takes an Object `opts` rather than a Number in + a backwards-compatible way. This `opts` allows syncing configuration options + to be specified including `includeArchivedRooms` and `resolveInvitesToProfiles`. + * `Room` objects which represent room invitations will now have state populated + from `invite_room_state` if it is included in the `m.room.member` event. + * `Room.getAvatarUrl` will now take into account `m.room.avatar` events. + +Changes in 0.2.2 +================ + +Bug fixes: + * Null pointer fixes for VoIP calling and push notification processing. + * Set the `Content-Type` to `application/octet-stream` in the event that the + file object has no `type`. + +New methods: + * Added `MatrixClient.getCasServer()` which calls through to the HTTP endpoint + `/login/cas`. + * Added `MatrixClient.loginWithCas(ticket, service)` which logs in with the + type `m.login.cas`. + * Added `MatrixClient.getHomeserverUrl()` which returns the URL passed in the + constructor. + * Added `MatrixClient.getIdentityServerUrl()` which returns the URL passed in + the constructor. + * Added `getLastModifiedTime()` to `RoomMember`, `RoomState` and `User` objects. + This makes it easier to see if the object in question has changed, which can + be used to improve performance by only rendering when these objects change. + +Changes in 0.2.1 +================ + +**BREAKING CHANGES** + * `MatrixClient.joinRoom` has changed from `(roomIdOrAlias, callback)` to + `(roomIdOrAlias, opts, callback)`. + +Bug fixes: + * The `Content-Type` of file uploads is now explicitly set, without relying + on the browser to do it for us. + +Improvements: + * The `MatrixScheduler.RETRY_BACKOFF_RATELIMIT` function will not retry when + the response is a 400,401,403. + * The text returned from a room invite now includes who the invite was from. + * There is now a try/catch block around the `request` function which will + reject/errback appropriately if an exception is thrown synchronously in it. + +New methods: + * `MatrixClient.createAlias(alias, roomId)` + * `MatrixClient.getRoomIdForAlias(alias)` + * `MatrixClient.sendNotice(roomId, body, txnId, callback)` + * `MatrixClient.sendHtmlNotice(roomId, body, htmlBody, callback)` + +Modified methods: + * `MatrixClient.joinRoom(roomIdOrAlias, opts)` where `opts` can include a + `syncRoom: true|false` flag to control whether a room initial sync is + performed after joining the room. + * `MatrixClient.getAvatarUrlForMember` has a new last arg `allowDefault` which + returns the default identicon URL if `true`. + * `MatrixClient.getAvatarUrlForRoom` has a new last arg `allowDefault` which + is passed through to the default identicon generation for + `getAvatarUrlForMember`. + + +Changes in 0.2.0 +================ + +**BREAKING CHANGES**: + * `MatrixClient.setPowerLevel` now expects a `MatrixEvent` and not an `Object` + for the `event` parameter. + +New features: + * Added `EventStatus.QUEUED` which is set on an event when it is waiting to be + sent by the scheduler and there are other events in front. + * Added support for processing push rules on an event. This can be obtained by + calling `MatrixClient.getPushActionsForEvent(event)`. + * Added WebRTC support. Outbound calls can be made via + `call = global.createNewMatrixCall(MatrixClient, roomId)` followed by + `call.placeVoiceCall()` or `call.placeVideoCall(remoteEle, localEle)`. + Inbound calls will be received via the event `"Call.incoming"` which provides + a call object which can be followed with `call.answer()` or `call.hangup()`. + * Added the ability to upload files to the media repository. + * Added the ability to change the client's password. + * Added the ability to register with an email via an identity server. + * Handle presence events by updating the associated `User` object. + * Handle redaction events. + * Added infrastructure for supporting End-to-End encryption. E2E is *NOT* + available in this version. + +New methods: + * `MatrixClient.getUser(userId)` + * `MatrixClient.getPushActionsForEvent(event)` + * `MatrixClient.setPassword(auth, newPassword)` + * `MatrixClient.loginWithSAML2(relayState, callback)` + * `MatrixClient.getAvatarUrlForMember(member, w, h, method)` + * `MatrixClient.mxcUrlToHttp(url, w, h, method)` + * `MatrixClient.getAvatarUrlForRoom(room, w, h, method)` + * `MatrixClient.uploadContent(file, callback)` + * `Room.getMembersWithMembership(membership)` + * `MatrixScheduler.getQueueForEvent(event)` + * `MatrixScheduler.removeEventFromQueue(event)` + * `$DATA_STORE.setSyncToken(token)` + * `$DATA_STORE.getSyncToken()` + +Crypto infrastructure (crypto is *NOT* available in this version): + * `global.CRYPTO_ENABLED` + * `MatrixClient.isCryptoEnabled()` + * `MatrixClient.uploadKeys(maxKeys)` + * `MatrixClient.downloadKeys(userIds, forceDownload)` + * `MatrixClient.listDeviceKeys(userId)` + * `MatrixClient.setRoomEncryption(roomId, config)` + * `MatrixClient.isRoomEncrypted(roomId)` + +New classes: + * `MatrixCall` + * `WebStorageStore` - *WIP; unstable* + * `WebStorageSessionStore` - *WIP; unstable* + +Bug fixes: + * Member name bugfix: Fixed an issue which prevented `RoomMember.name` being + disambiguated if there was exactly 1 other person with the same display name. + * Member name bugfix: Disambiguate both clashing display names with user IDs in + the event of a clash. + * Room state bugfix: Fixed a bug which incorrectly overwrote power levels + locally for a room. + * Room name bugfix: Ignore users who have left the room when determining a room + name. + * Events bugfix: Fixed a bug which prevented the `sender` and `target` + properties from being set. + +Changes in 0.1.1 +================ + +**BREAKING CHANGES**: + * `Room.calculateRoomName` is now private. Use `Room.recalculate` instead, and + access the calculated name via `Room.name`. + * `new MatrixClient(...)` no longer creates a `MatrixInMemoryStore` if + `opts.store` is not specified. Instead, the `createClient` global function + creates it and passes it to the constructor. This change will not affect + users who have always used `createClient` to create a `MatrixClient`. + * `"Room"` events will now be emitted when the Room has *finished* being + populated with state rather than at the moment of creation. This will fire + when the SDK encounters a room it doesn't know about (just arrived from the + event stream; e.g. a room invite) and will also fire after syncing room + state (e.g. after calling joinRoom). + * `MatrixClient.joinRoom` now returns a `Room` object when resolved, not an + object with a `room_id` property. + * `MatrixClient.scrollback` now expects a `Room` arg instead of a `room_id` + and `from` token. Construct a `new Room(roomId)` if you want to continue + using this directly, then set the pagination token using + `room.oldState.paginationToken = from`. It now resolves to a `Room` object + instead of the raw HTTP response. + +New properties: + * `User.events` + * `RoomMember.events` + +New methods: + * `Room.hasMembershipState(userId, membership)` + * `MatrixClient.resendEvent(event, room)` + +New features: + * Local echo. When you send an event using the SDK it will immediately be + added to `Room.timeline` with the `event.status` of `EventStatus.SENDING`. + When the event is finally sent, this status will be removed. + * Not sent status. When an event fails to send using the SDK, it will have the + `event.status` of `EventStatus.NOT_SENT`. + * Retries. If events fail to send, they will be automatically retried. + * Manual resending. Events which failed to send can be passed to + `MatrixClient.resendEvent(event, room)` to resend them. + * Queueing. Messages sent in quick succession will be queued to preserve the + order in which they were submitted. + * Room state is automatcally synchronised when joining a room (including if + another device joins a room). + * Scrollback. You can request earlier events in a room using + `MatrixClient.scrollback(room, limit, callback)`. + +Bug fixes: + * Fixed a bug which prevented the event stream from polling. Some devices will + black hole requests when they hibernate, meaning that the callbacks will + never fire. We now maintain a local timer to forcibly restart the request. diff --git a/matrix-js-sdk/CONTRIBUTING.rst b/matrix-js-sdk/CONTRIBUTING.rst new file mode 100644 index 000000000..b4b7f67f1 --- /dev/null +++ b/matrix-js-sdk/CONTRIBUTING.rst @@ -0,0 +1,124 @@ +Contributing code to matrix-js-sdk +================================== + +Everyone is welcome to contribute code to matrix-js-sdk, provided that they are +willing to license their contributions under the same license as the project +itself. We follow a simple 'inbound=outbound' model for contributions: the act +of submitting an 'inbound' contribution means that the contributor agrees to +license the code under the same terms as the project's overall 'outbound' +license - in this case, Apache Software License v2 (see ``_). + +How to contribute +~~~~~~~~~~~~~~~~~ + +The preferred and easiest way to contribute changes to the project is to fork +it on github, and then create a pull request to ask us to pull your changes +into our repo (https://help.github.com/articles/using-pull-requests/) + +**The single biggest thing you need to know is: please base your changes on +the develop branch - /not/ master.** + +We use the master branch to track the most recent release, so that folks who +blindly clone the repo and automatically check out master get something that +works. Develop is the unstable branch where all the development actually +happens: the workflow is that contributors should fork the develop branch to +make a 'feature' branch for a particular contribution, and then make a pull +request to merge this back into the matrix.org 'official' develop branch. We +use GitHub's pull request workflow to review the contribution, and either ask +you to make any refinements needed or merge it and make them ourselves. The +changes will then land on master when we next do a release. + +We use Travis for continuous integration, and all pull requests get +automatically tested by Travis: if your change breaks the build, then the PR +will show that there are failed checks, so please check back after a few +minutes. + +Code style +~~~~~~~~~~ + +The code-style for matrix-js-sdk is not formally documented, but contributors +are encouraged to read the code style document for matrix-react-sdk +(``_) +and follow the principles set out there. + +Please ensure your changes match the cosmetic style of the existing project, +and **never** mix cosmetic and functional changes in the same commit, as it +makes it horribly hard to review otherwise. + +Attribution +~~~~~~~~~~~ + +Everyone who contributes anything to Matrix is welcome to be listed in the +AUTHORS.rst file for the project in question. Please feel free to include a +change to AUTHORS.rst in your pull request to list yourself and a short +description of the area(s) you've worked on. Also, we sometimes have swag to +give away to contributors - if you feel that Matrix-branded apparel is missing +from your life, please mail us your shipping address to matrix at matrix.org +and we'll try to fix it :) + +Sign off +~~~~~~~~ + +In order to have a concrete record that your contribution is intentional +and you agree to license it under the same terms as the project's license, we've +adopted the same lightweight approach that the Linux Kernel +(https://www.kernel.org/doc/Documentation/SubmittingPatches), Docker +(https://github.com/docker/docker/blob/master/CONTRIBUTING.md), and many other +projects use: the DCO (Developer Certificate of Origin: +http://developercertificate.org/). This is a simple declaration that you wrote +the contribution or otherwise have the right to contribute it to Matrix:: + + Developer Certificate of Origin + Version 1.1 + + Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + 660 York Street, Suite 102, + San Francisco, CA 94110 USA + + Everyone is permitted to copy and distribute verbatim copies of this + license document, but changing it is not allowed. + + Developer's Certificate of Origin 1.1 + + By making a contribution to this project, I certify that: + + (a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + + (b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + + (c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + + (d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. + +If you agree to this for your contribution, then all that's needed is to +include the line in your commit or pull request comment:: + + Signed-off-by: Your Name + +We accept contributions under a legally identifiable name, such as your name on +government documentation or common-law names (names claimed by legitimate usage +or repute). Unfortunately, we cannot accept anonymous contributions at this +time. + +Git allows you to add this signoff automatically when using the ``-s`` flag to +``git commit``, which uses the name and email set in your ``user.name`` and +``user.email`` git configs. + +If you forgot to sign off your commits before making your pull request and are +on Git 2.17+ you can mass signoff using rebase:: + + git rebase --signoff origin/develop diff --git a/matrix-js-sdk/LICENSE b/matrix-js-sdk/LICENSE new file mode 100644 index 000000000..f433b1a53 --- /dev/null +++ b/matrix-js-sdk/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/matrix-js-sdk/README.md b/matrix-js-sdk/README.md new file mode 100644 index 000000000..0504eabcc --- /dev/null +++ b/matrix-js-sdk/README.md @@ -0,0 +1,367 @@ +Matrix Javascript SDK +===================== + +This is the [Matrix](https://matrix.org) Client-Server r0 SDK for +JavaScript. This SDK can be run in a browser or in Node.js. + +Quickstart +========== + +In a browser +------------ +Download either the full or minified version from +https://github.com/matrix-org/matrix-js-sdk/releases/latest and add that as a +`` + + + + Sanity Testing (check the console) : This example is here to make sure that + the SDK works inside a browser. It simply does a GET /publicRooms on + matrix.org +
+ You should see a message confirming that the SDK works below: +
+
+ + diff --git a/matrix-js-sdk/examples/browser/lib/matrix.js b/matrix-js-sdk/examples/browser/lib/matrix.js new file mode 120000 index 000000000..518d47ddb --- /dev/null +++ b/matrix-js-sdk/examples/browser/lib/matrix.js @@ -0,0 +1 @@ +../../../dist/browser-matrix.js \ No newline at end of file diff --git a/matrix-js-sdk/examples/node/README.md b/matrix-js-sdk/examples/node/README.md new file mode 100644 index 000000000..0c96940df --- /dev/null +++ b/matrix-js-sdk/examples/node/README.md @@ -0,0 +1,46 @@ +This is a functional terminal app which allows you to see the room list for a user, join rooms, send messages and view room membership lists. + + +To try it out, you will need to edit `app.js` to configure it for your `homeserver`, `access_token` and `user_id`. Then run: + +``` + $ npm install + $ node app +``` + +Example output: + +``` +Room List: +[0] Room Invite (0 members) +[1] Room Invite (0 members) +[2] My New Room (2 members) +[3] @megan:localhost (1 members) +[4] True Stuff (7 members) +Global commands: + '/help' : Show this help. +Room list index commands: + '/enter ' Enter a room, e.g. '/enter 5' +Room commands: + '/exit' Return to the room list index. + '/members' Show the room member list. + +$ /enter 2 + +[2015-06-12 15:14:54] Megan2 <<< herro +[2015-06-12 15:22:58] Me >>> hey +[2015-06-12 15:23:00] Me >>> whats up? +[2015-06-12 15:25:40] Megan2 <<< not a lot +[2015-06-12 15:25:47] Megan2 --- [State: m.room.topic updated to: {"topic":"xXx_topic_goes_here_xXx"}] +[2015-06-12 15:25:55] Megan2 --- [State: m.room.name updated to: {"name":"My Newer Room"}] + +$ /members + +Membership list for room "My Newer Room" +---------------------------------------- +join :: @example:localhost (Me) +leave :: @fred:localhost (@fred:localhost) +invite :: @earl:localhost (@earl:localhost) +join :: Megan2 (@megan:localhost) +invite :: @toejam:localhost (@toejam:localhost) +``` diff --git a/matrix-js-sdk/examples/node/app.js b/matrix-js-sdk/examples/node/app.js new file mode 100644 index 000000000..fd486830b --- /dev/null +++ b/matrix-js-sdk/examples/node/app.js @@ -0,0 +1,410 @@ +"use strict"; + +var myUserId = "@example:localhost"; +var myAccessToken = "QGV4YW1wbGU6bG9jYWxob3N0.qPEvLuYfNBjxikiCjP"; +var sdk = require("matrix-js-sdk"); +var clc = require("cli-color"); +var matrixClient = sdk.createClient({ + baseUrl: "http://localhost:8008", + accessToken: myAccessToken, + userId: myUserId +}); + +// Data structures +var roomList = []; +var viewingRoom = null; +var numMessagesToShow = 20; + +// Reading from stdin +var CLEAR_CONSOLE = '\x1B[2J'; +var readline = require("readline"); +var rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + completer: completer +}); +rl.setPrompt("$ "); +rl.on('line', function(line) { + if (line.trim().length === 0) { + rl.prompt(); + return; + } + if (line === "/help") { + printHelp(); + rl.prompt(); + return; + } + + if (viewingRoom) { + if (line === "/exit") { + viewingRoom = null; + printRoomList(); + } + else if (line === "/members") { + printMemberList(viewingRoom); + } + else if (line === "/roominfo") { + printRoomInfo(viewingRoom); + } + else if (line === "/resend") { + // get the oldest not sent event. + var notSentEvent; + for (var i = 0; i < viewingRoom.timeline.length; i++) { + if (viewingRoom.timeline[i].status == sdk.EventStatus.NOT_SENT) { + notSentEvent = viewingRoom.timeline[i]; + break; + } + } + if (notSentEvent) { + matrixClient.resendEvent(notSentEvent, viewingRoom).done(function() { + printMessages(); + rl.prompt(); + }, function(err) { + printMessages(); + print("/resend Error: %s", err); + rl.prompt(); + }); + printMessages(); + rl.prompt(); + } + } + else if (line.indexOf("/more ") === 0) { + var amount = parseInt(line.split(" ")[1]) || 20; + matrixClient.scrollback(viewingRoom, amount).done(function(room) { + printMessages(); + rl.prompt(); + }, function(err) { + print("/more Error: %s", err); + }); + } + else if (line.indexOf("/invite ") === 0) { + var userId = line.split(" ")[1].trim(); + matrixClient.invite(viewingRoom.roomId, userId).done(function() { + printMessages(); + rl.prompt(); + }, function(err) { + print("/invite Error: %s", err); + }); + } + else if (line.indexOf("/file ") === 0) { + var filename = line.split(" ")[1].trim(); + var stream = fs.createReadStream(filename); + matrixClient.uploadContent({ + stream: stream, + name: filename + }).done(function(url) { + var content = { + msgtype: "m.file", + body: filename, + url: JSON.parse(url).content_uri + }; + matrixClient.sendMessage(viewingRoom.roomId, content); + }); + } + else { + matrixClient.sendTextMessage(viewingRoom.roomId, line).finally(function() { + printMessages(); + rl.prompt(); + }); + // print local echo immediately + printMessages(); + } + } + else { + if (line.indexOf("/join ") === 0) { + var roomIndex = line.split(" ")[1]; + viewingRoom = roomList[roomIndex]; + if (viewingRoom.getMember(myUserId).membership === "invite") { + // join the room first + matrixClient.joinRoom(viewingRoom.roomId).done(function(room) { + setRoomList(); + viewingRoom = room; + printMessages(); + rl.prompt(); + }, function(err) { + print("/join Error: %s", err); + }); + } + else { + printMessages(); + } + } + } + rl.prompt(); +}); +// ==== END User input + +// show the room list after syncing. +matrixClient.on("sync", function(state, prevState, data) { + switch (state) { + case "PREPARED": + setRoomList(); + printRoomList(); + printHelp(); + rl.prompt(); + break; + } +}); + +matrixClient.on("Room", function() { + setRoomList(); + if (!viewingRoom) { + printRoomList(); + rl.prompt(); + } +}); + +// print incoming messages. +matrixClient.on("Room.timeline", function(event, room, toStartOfTimeline) { + if (toStartOfTimeline) { + return; // don't print paginated results + } + if (!viewingRoom || viewingRoom.roomId !== room.roomId) { + return; // not viewing a room or viewing the wrong room. + } + printLine(event); +}); + +function setRoomList() { + roomList = matrixClient.getRooms(); + roomList.sort(function(a,b) { + // < 0 = a comes first (lower index) - we want high indexes = newer + var aMsg = a.timeline[a.timeline.length-1]; + if (!aMsg) { + return -1; + } + var bMsg = b.timeline[b.timeline.length-1]; + if (!bMsg) { + return 1; + } + if (aMsg.getTs() > bMsg.getTs()) { + return 1; + } + else if (aMsg.getTs() < bMsg.getTs()) { + return -1; + } + return 0; + }); +} + +function printRoomList() { + print(CLEAR_CONSOLE); + print("Room List:"); + var fmts = { + "invite": clc.cyanBright, + "leave": clc.blackBright + }; + for (var i = 0; i < roomList.length; i++) { + var msg = roomList[i].timeline[roomList[i].timeline.length-1]; + var dateStr = "---"; + var fmt; + if (msg) { + dateStr = new Date(msg.getTs()).toISOString().replace( + /T/, ' ').replace(/\..+/, ''); + } + var myMembership = roomList[i].getMyMembership(); + if (myMembership) { + fmt = fmts[myMembership]; + } + var roomName = fixWidth(roomList[i].name, 25); + print( + "[%s] %s (%s members) %s", + i, fmt ? fmt(roomName) : roomName, + roomList[i].getJoinedMembers().length, + dateStr + ); + } +} + +function printHelp() { + var hlp = clc.italic.white; + print("Global commands:", hlp); + print(" '/help' : Show this help.", clc.white); + print("Room list index commands:", hlp); + print(" '/join ' Join a room, e.g. '/join 5'", clc.white); + print("Room commands:", hlp); + print(" '/exit' Return to the room list index.", clc.white); + print(" '/members' Show the room member list.", clc.white); + print(" '/invite @foo:bar' Invite @foo:bar to the room.", clc.white); + print(" '/more 15' Scrollback 15 events", clc.white); + print(" '/resend' Resend the oldest event which failed to send.", clc.white); + print(" '/roominfo' Display room info e.g. name, topic.", clc.white); +} + +function completer(line) { + var completions = [ + "/help", "/join ", "/exit", "/members", "/more ", "/resend", "/invite" + ]; + var hits = completions.filter(function(c) { return c.indexOf(line) == 0 }); + // show all completions if none found + return [hits.length ? hits : completions, line] +} + +function printMessages() { + if (!viewingRoom) { + printRoomList(); + return; + } + print(CLEAR_CONSOLE); + var mostRecentMessages = viewingRoom.timeline; + for (var i = 0; i < mostRecentMessages.length; i++) { + printLine(mostRecentMessages[i]); + } +} + +function printMemberList(room) { + var fmts = { + "join": clc.green, + "ban": clc.red, + "invite": clc.blue, + "leave": clc.blackBright + }; + var members = room.currentState.getMembers(); + // sorted based on name. + members.sort(function(a, b) { + if (a.name > b.name) { + return -1; + } + if (a.name < b.name) { + return 1; + } + return 0; + }); + print("Membership list for room \"%s\"", room.name); + print(new Array(room.name.length + 28).join("-")); + room.currentState.getMembers().forEach(function(member) { + if (!member.membership) { + return; + } + var fmt = fmts[member.membership] || function(a){return a;}; + var membershipWithPadding = ( + member.membership + new Array(10 - member.membership.length).join(" ") + ); + print( + "%s"+fmt(" :: ")+"%s"+fmt(" (")+"%s"+fmt(")"), + membershipWithPadding, member.name, + (member.userId === myUserId ? "Me" : member.userId), + fmt + ); + }); +} + +function printRoomInfo(room) { + var eventDict = room.currentState.events; + var eTypeHeader = " Event Type(state_key) "; + var sendHeader = " Sender "; + // pad content to 100 + var restCount = ( + 100 - "Content".length - " | ".length - " | ".length - + eTypeHeader.length - sendHeader.length + ); + var padSide = new Array(Math.floor(restCount/2)).join(" "); + var contentHeader = padSide + "Content" + padSide; + print(eTypeHeader+sendHeader+contentHeader); + print(new Array(100).join("-")); + Object.keys(eventDict).forEach(function(eventType) { + if (eventType === "m.room.member") { return; } // use /members instead. + Object.keys(eventDict[eventType]).forEach(function(stateKey) { + var typeAndKey = eventType + ( + stateKey.length > 0 ? "("+stateKey+")" : "" + ); + var typeStr = fixWidth(typeAndKey, eTypeHeader.length); + var event = eventDict[eventType][stateKey]; + var sendStr = fixWidth(event.getSender(), sendHeader.length); + var contentStr = fixWidth( + JSON.stringify(event.getContent()), contentHeader.length + ); + print(typeStr+" | "+sendStr+" | "+contentStr); + }); + }) +} + +function printLine(event) { + var fmt; + var name = event.sender ? event.sender.name : event.getSender(); + var time = new Date( + event.getTs() + ).toISOString().replace(/T/, ' ').replace(/\..+/, ''); + var separator = "<<<"; + if (event.getSender() === myUserId) { + name = "Me"; + separator = ">>>"; + if (event.status === sdk.EventStatus.SENDING) { + separator = "..."; + fmt = clc.xterm(8); + } + else if (event.status === sdk.EventStatus.NOT_SENT) { + separator = " x "; + fmt = clc.redBright; + } + } + var body = ""; + + var maxNameWidth = 15; + if (name.length > maxNameWidth) { + name = name.substr(0, maxNameWidth-1) + "\u2026"; + } + + if (event.getType() === "m.room.message") { + body = event.getContent().body; + } + else if (event.isState()) { + var stateName = event.getType(); + if (event.getStateKey().length > 0) { + stateName += " ("+event.getStateKey()+")"; + } + body = ( + "[State: "+stateName+" updated to: "+JSON.stringify(event.getContent())+"]" + ); + separator = "---"; + fmt = clc.xterm(249).italic; + } + else { + // random message event + body = ( + "[Message: "+event.getType()+" Content: "+JSON.stringify(event.getContent())+"]" + ); + separator = "---"; + fmt = clc.xterm(249).italic; + } + if (fmt) { + print( + "[%s] %s %s %s", time, name, separator, body, fmt + ); + } + else { + print("[%s] %s %s %s", time, name, separator, body); + } +} + +function print(str, formatter) { + if (typeof arguments[arguments.length-1] === "function") { + // last arg is the formatter so get rid of it and use it on each + // param passed in but not the template string. + var newArgs = []; + var i = 0; + for (i=0; i len) { + return str.substr(0, len-2) + "\u2026"; + } + else if (str.length < len) { + return str + new Array(len - str.length).join(" "); + } + return str; +} + +matrixClient.startClient(numMessagesToShow); // messages for each room. diff --git a/matrix-js-sdk/examples/node/package.json b/matrix-js-sdk/examples/node/package.json new file mode 100644 index 000000000..6319e367d --- /dev/null +++ b/matrix-js-sdk/examples/node/package.json @@ -0,0 +1,14 @@ +{ + "name": "example-app", + "version": "0.0.0", + "description": "", + "main": "app.js", + "scripts": { + "preinstall": "npm install ../.." + }, + "author": "", + "license": "Apache 2.0", + "dependencies": { + "cli-color": "^1.0.0" + } +} diff --git a/matrix-js-sdk/examples/voip/README.md b/matrix-js-sdk/examples/voip/README.md new file mode 100644 index 000000000..c3f3d67dd --- /dev/null +++ b/matrix-js-sdk/examples/voip/README.md @@ -0,0 +1,9 @@ +To try it out, **you must build the SDK first** and then host this folder: + +``` + $ npm run build + $ cd examples/voip + $ python -m SimpleHTTPServer 8003 +``` + +Then visit ``http://localhost:8003``. diff --git a/matrix-js-sdk/examples/voip/browserTest.js b/matrix-js-sdk/examples/voip/browserTest.js new file mode 100644 index 000000000..a2f15888a --- /dev/null +++ b/matrix-js-sdk/examples/voip/browserTest.js @@ -0,0 +1,97 @@ +"use strict"; +console.log("Loading browser sdk"); +var BASE_URL = "https://matrix.org"; +var TOKEN = "accesstokengoeshere"; +var USER_ID = "@username:localhost"; +var ROOM_ID = "!room:id"; + + +var client = matrixcs.createClient({ + baseUrl: BASE_URL, + accessToken: TOKEN, + userId: USER_ID +}); +var call; + +function disableButtons(place, answer, hangup) { + document.getElementById("hangup").disabled = hangup; + document.getElementById("answer").disabled = answer; + document.getElementById("call").disabled = place; +} + +function addListeners(call) { + var lastError = ""; + call.on("hangup", function() { + disableButtons(false, true, true); + document.getElementById("result").innerHTML = ( + "

Call ended. Last error: "+lastError+"

" + ); + }); + call.on("error", function(err) { + lastError = err.message; + call.hangup(); + disableButtons(false, true, true); + }); +} + +window.onload = function() { + document.getElementById("result").innerHTML = "

Please wait. Syncing...

"; + document.getElementById("config").innerHTML = "

" + + "Homeserver: "+BASE_URL+"
"+ + "Room: "+ROOM_ID+"
"+ + "User: "+USER_ID+"
"+ + "

"; + disableButtons(true, true, true); +}; + +client.on("sync", function(state, prevState, data) { + switch (state) { + case "PREPARED": + syncComplete(); + break; + } +}); + +function syncComplete() { + document.getElementById("result").innerHTML = "

Ready for calls.

"; + disableButtons(false, true, true); + + document.getElementById("call").onclick = function() { + console.log("Placing call..."); + call = matrixcs.createNewMatrixCall( + client, ROOM_ID + ); + console.log("Call => %s", call); + addListeners(call); + call.placeVideoCall( + document.getElementById("remote"), + document.getElementById("local") + ); + document.getElementById("result").innerHTML = "

Placed call.

"; + disableButtons(true, true, false); + }; + + document.getElementById("hangup").onclick = function() { + console.log("Hanging up call..."); + console.log("Call => %s", call); + call.hangup(); + document.getElementById("result").innerHTML = "

Hungup call.

"; + }; + + document.getElementById("answer").onclick = function() { + console.log("Answering call..."); + console.log("Call => %s", call); + call.answer(); + disableButtons(true, true, false); + document.getElementById("result").innerHTML = "

Answered call.

"; + }; + + client.on("Call.incoming", function(c) { + console.log("Call ringing"); + disableButtons(true, false, false); + document.getElementById("result").innerHTML = "

Incoming call...

"; + call = c; + addListeners(call); + }); +} +client.startClient(); diff --git a/matrix-js-sdk/examples/voip/index.html b/matrix-js-sdk/examples/voip/index.html new file mode 100644 index 000000000..a3259cfa1 --- /dev/null +++ b/matrix-js-sdk/examples/voip/index.html @@ -0,0 +1,26 @@ + + +VoIP Test + + + + + You can place and receive calls with this example. Make sure to edit the + constants in browserTest.js first. +
+
+ + + +
+
+ +
+
+
+
+ +
+
+ + diff --git a/matrix-js-sdk/examples/voip/lib/matrix.js b/matrix-js-sdk/examples/voip/lib/matrix.js new file mode 120000 index 000000000..518d47ddb --- /dev/null +++ b/matrix-js-sdk/examples/voip/lib/matrix.js @@ -0,0 +1 @@ +../../../dist/browser-matrix.js \ No newline at end of file diff --git a/matrix-js-sdk/git-hooks/pre-commit b/matrix-js-sdk/git-hooks/pre-commit new file mode 100755 index 000000000..b6988ae7a --- /dev/null +++ b/matrix-js-sdk/git-hooks/pre-commit @@ -0,0 +1,24 @@ +#!/bin/sh +# +# pre-commit: script to run checks on a working copy before commit +# +# To use, symlink it into .git/hooks: +# ln -s ../../git-hooks/pre-commit .git/hooks +# + +set -e + +# create a temp dir +tmpdir=`mktemp -d` +trap 'rm -rf "$tmpdir"' EXIT + +# get a copy of the index +git checkout-index --prefix="$tmpdir/" -a + +# keep node_modules/.bin on the path +rootdir=`git rev-parse --show-toplevel` +export PATH="$rootdir/node_modules/.bin:$PATH" + +# now run our checks +cd "$tmpdir" +yarn lint diff --git a/matrix-js-sdk/index.js b/matrix-js-sdk/index.js new file mode 100644 index 000000000..35845b1f7 --- /dev/null +++ b/matrix-js-sdk/index.js @@ -0,0 +1,6 @@ +var matrixcs = require("./lib/matrix"); +matrixcs.request(require("request")); +module.exports = matrixcs; + +var utils = require("./lib/utils"); +utils.runPolyfills(); diff --git a/matrix-js-sdk/jenkins.sh b/matrix-js-sdk/jenkins.sh new file mode 100755 index 000000000..eb4543936 --- /dev/null +++ b/matrix-js-sdk/jenkins.sh @@ -0,0 +1,36 @@ +#!/bin/bash -l + +set -x + +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + +nvm use 10 || exit $? +yarn install || exit $? + +RC=0 + +function fail { + echo $@ >&2 + RC=1 +} + +# don't use last time's test reports +rm -rf reports coverage || exit $? + +yarn test || fail "yarn test finished with return code $?" + +yarn -s lint -f checkstyle > eslint.xml || + fail "eslint finished with return code $?" + +# delete the old tarball, if it exists +rm -f matrix-js-sdk-*.tgz + +# `yarn pack` doesn't seem to run scripts, however that seems okay here as we +# just built as part of `install` above. +yarn pack || + fail "yarn pack finished with return code $?" + +yarn gendoc || fail "JSDoc failed with code $?" + +exit $RC diff --git a/matrix-js-sdk/package.json b/matrix-js-sdk/package.json new file mode 100644 index 000000000..ca6c350dc --- /dev/null +++ b/matrix-js-sdk/package.json @@ -0,0 +1,97 @@ +{ + "name": "matrix-js-sdk", + "version": "2.2.0-tchap-2.5.6", + "description": "Matrix Client-Server SDK for Javascript", + "main": "index.js", + "scripts": { + "test:build": "babel -s -d specbuild spec", + "test:run": "istanbul cover --report text --report cobertura --config .istanbul.yml -i \"lib/**/*.js\" node_modules/mocha/bin/_mocha -- --recursive specbuild --colors --reporter mocha-jenkins-reporter --reporter-options junit_report_path=reports/test-results.xml", + "test:watch": "mocha --watch --compilers js:babel-core/register --recursive spec --colors", + "test": "yarn test:build && yarn test:run", + "check": "yarn test:build && _mocha --recursive specbuild --colors", + "gendoc": "babel --no-babelrc --plugins transform-class-properties -d .jsdocbuild src && jsdoc -r .jsdocbuild -P package.json -R README.md -d .jsdoc", + "start": "yarn start:init && yarn start:watch", + "start:watch": "babel -s -w --skip-initial-build -d lib src", + "start:init": "babel -s -d lib src", + "clean": "rimraf lib dist", + "build": "babel -s -d lib src && rimraf dist && mkdir dist && browserify -d browser-index.js | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js && terser -c -m -o dist/browser-matrix.min.js --source-map \"content='dist/browser-matrix.js.map'\" dist/browser-matrix.js", + "dist": "yarn build", + "watch": "watchify -d browser-index.js -o 'exorcist dist/browser-matrix.js.map > dist/browser-matrix.js' -v", + "lint": "eslint --max-warnings 101 src spec" + }, + "repository": { + "type": "git", + "url": "https://github.com/matrix-org/matrix-js-sdk" + }, + "keywords": [ + "matrix-org" + ], + "browser": "browser-index.js", + "author": "matrix.org", + "license": "Apache-2.0", + "files": [ + ".babelrc", + ".eslintrc.js", + "spec/.eslintrc.js", + "CHANGELOG.md", + "CONTRIBUTING.rst", + "LICENSE", + "README.md", + "RELEASING.md", + "examples", + "git-hooks", + "git-revision.txt", + "index.js", + "browser-index.js", + "jenkins.sh", + "lib", + "package.json", + "release.sh", + "spec", + "src" + ], + "dependencies": { + "another-json": "^0.2.0", + "babel-runtime": "^6.26.0", + "bluebird": "^3.5.0", + "browser-request": "^0.3.3", + "bs58": "^4.0.1", + "content-type": "^1.0.2", + "loglevel": "1.6.1", + "qs": "^6.5.2", + "request": "^2.88.0", + "unhomoglyph": "^1.0.2" + }, + "devDependencies": { + "babel-cli": "^6.18.0", + "babel-eslint": "^10.0.1", + "babel-plugin-transform-async-to-bluebird": "^1.1.1", + "babel-plugin-transform-class-properties": "^6.24.1", + "babel-plugin-transform-runtime": "^6.23.0", + "babel-preset-es2015": "^6.18.0", + "browserify": "^16.2.3", + "browserify-shim": "^3.8.13", + "eslint": "^5.12.0", + "eslint-config-google": "^0.7.1", + "eslint-plugin-babel": "^5.3.0", + "exorcist": "^0.4.0", + "expect": "^1.20.2", + "istanbul": "^0.4.5", + "jsdoc": "^3.5.5", + "lolex": "^1.5.2", + "matrix-mock-request": "^1.2.3", + "mocha": "^5.2.0", + "mocha-jenkins-reporter": "^0.4.0", + "olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz", + "rimraf": "^2.5.4", + "source-map-support": "^0.4.11", + "sourceify": "^0.1.0", + "terser": "^4.0.0", + "watchify": "^3.11.1" + }, + "browserify": { + "transform": [ + "sourceify" + ] + } +} diff --git a/matrix-js-sdk/release.sh b/matrix-js-sdk/release.sh new file mode 100755 index 000000000..3ff018c67 --- /dev/null +++ b/matrix-js-sdk/release.sh @@ -0,0 +1,331 @@ +#!/bin/bash +# +# Script to perform a release of matrix-js-sdk. +# +# Requires: +# github-changelog-generator; install via: +# pip install git+https://github.com/matrix-org/github-changelog-generator.git +# jq; install from your distribution's package manager (https://stedolan.github.io/jq/) +# hub; install via brew (macOS) or source/pre-compiled binaries (debian) (https://github.com/github/hub) - Tested on v2.2.9 +# npm; typically installed by Node.js +# yarn; install via brew (macOS) or similar (https://yarnpkg.com/docs/install/) + +set -e + +jq --version > /dev/null || (echo "jq is required: please install it"; kill $$) +if [[ `command -v hub` ]] && [[ `hub --version` =~ hub[[:space:]]version[[:space:]]([0-9]*).([0-9]*) ]]; then + HUB_VERSION_MAJOR=${BASH_REMATCH[1]} + HUB_VERSION_MINOR=${BASH_REMATCH[2]} + if [[ $HUB_VERSION_MAJOR -lt 2 ]] || [[ $HUB_VERSION_MAJOR -eq 2 && $HUB_VERSION_MINOR -lt 5 ]]; then + echo "hub version 2.5 is required, you have $HUB_VERSION_MAJOR.$HUB_VERSION_MINOR installed" + exit + fi +else + echo "hub is required: please install it" + exit +fi +npm --version > /dev/null || (echo "npm is required: please install it"; kill $$) +yarn --version > /dev/null || (echo "yarn is required: please install it"; kill $$) + +USAGE="$0 [-xz] [-c changelog_file] vX.Y.Z" + +help() { + cat <&2 + exit 1 +fi + +if [ -z "$skip_changelog" ]; then + # update_changelog doesn't have a --version flag + update_changelog -h > /dev/null || (echo "github-changelog-generator is required: please install it"; exit) +fi + +# Login and publish continues to use `npm`, as it seems to have more clearly +# defined options and semantics than `yarn` for writing to the registry. +actual_npm_user=`npm whoami`; +if [ $expected_npm_user != $actual_npm_user ]; then + echo "you need to be logged into npm as $expected_npm_user, but you are logged in as $actual_npm_user" >&2 + exit 1 +fi + +# ignore leading v on release +release="${1#v}" +tag="v${release}" +rel_branch="release-$tag" + +prerelease=0 +# We check if this build is a prerelease by looking to +# see if the version has a hyphen in it. Crude, +# but semver doesn't support postreleases so anything +# with a hyphen is a prerelease. +echo $release | grep -q '-' && prerelease=1 + +if [ $prerelease -eq 1 ]; then + echo Making a PRE-RELEASE +fi + +if [ -z "$skip_changelog" ]; then + if ! command -v update_changelog >/dev/null 2>&1; then + echo "release.sh requires github-changelog-generator. Try:" >&2 + echo " pip install git+https://github.com/matrix-org/github-changelog-generator.git" >&2 + exit 1 + fi +fi + +# we might already be on the release branch, in which case, yay +# If we're on any branch starting with 'release', we don't create +# a separate release branch (this allows us to use the same +# release branch for releases and release candidates). +curbranch=$(git symbolic-ref --short HEAD) +if [[ "$curbranch" != release* ]]; then + echo "Creating release branch" + git checkout -b "$rel_branch" +else + echo "Using current branch ($curbranch) for release" + rel_branch=$curbranch +fi + +if [ -z "$skip_changelog" ]; then + echo "Generating changelog" + update_changelog -f "$changelog_file" "$release" + read -p "Edit $changelog_file manually, or press enter to continue " REPLY + + if [ -n "$(git ls-files --modified $changelog_file)" ]; then + echo "Committing updated changelog" + git commit "$changelog_file" -m "Prepare changelog for $tag" + fi +fi +latest_changes=`mktemp` +cat "${changelog_file}" | `dirname $0`/scripts/changelog_head.py > "${latest_changes}" + +set -x + +# Bump package.json and build the dist +echo "yarn version" +# yarn version will automatically commit its modification +# and make a release tag. We don't want it to create the tag +# because it can only sign with the default key, but we can +# only turn off both of these behaviours, so we have to +# manually commit the result. +yarn version --no-git-tag-version --new-version "$release" + +# commit yarn.lock if it exists, is versioned, and is modified +if [[ -f yarn.lock && `git status --porcelain yarn.lock | grep '^ M'` ]]; +then + pkglock='yarn.lock' +else + pkglock='' +fi +git commit package.json $pkglock -m "$tag" + + +# figure out if we should be signing this release +signing_id= +if [ -f release_config.yaml ]; then + signing_id=`cat release_config.yaml | python -c "import yaml; import sys; print yaml.load(sys.stdin)['signing_id']"` +fi + + +# If there is a 'dist' script in the package.json, +# run it in a separate checkout of the project, then +# upload any files in the 'dist' directory as release +# assets. +# We make a completely separate checkout to be sure +# we're using released versions of the dependencies +# (rather than whatever we're pulling in from yarn link) +assets='' +dodist=0 +jq -e .scripts.dist package.json 2> /dev/null || dodist=$? +if [ $dodist -eq 0 ]; then + projdir=`pwd` + builddir=`mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir'` + echo "Building distribution copy in $builddir" + pushd "$builddir" + git clone "$projdir" . + git checkout "$rel_branch" + yarn install + # We haven't tagged yet, so tell the dist script what version + # it's building + DIST_VERSION="$tag" yarn dist + + popd + + for i in "$builddir"/dist/*; do + assets="$assets -a $i" + if [ -n "$signing_id" ] + then + gpg -u "$signing_id" --armor --output "$i".asc --detach-sig "$i" + assets="$assets -a $i.asc" + fi + done +fi + +if [ -n "$signing_id" ]; then + # make a signed tag + # gnupg seems to fail to get the right tty device unless we set it here + GIT_COMMITTER_EMAIL="$signing_id" GPG_TTY=`tty` git tag -u "$signing_id" -F "${latest_changes}" "$tag" +else + git tag -a -F "${latest_changes}" "$tag" +fi + +# push the tag and the release branch +git push origin "$rel_branch" "$tag" + +if [ -n "$signing_id" ]; then + # make a signature for the source tarball. + # + # github will make us a tarball from the tag - we want to create a + # signature for it, which means that first of all we need to check that + # it's correct. + # + # we can't deterministically build exactly the same tarball, due to + # differences in gzip implementation - but we *can* build the same tar - so + # the easiest way to check the validity of the tarball from git is to unzip + # it and compare it with our own idea of what the tar should look like. + + # the name of the sig file we want to create + source_sigfile="${tag}-src.tar.gz.asc" + + tarfile="$tag.tar.gz" + gh_project_url=$(git remote get-url origin | + sed -e 's#^git@github\.com:#https://github.com/#' \ + -e 's#^git\+ssh://git@github\.com/#https://github.com/#' \ + -e 's/\.git$//') + project_name="${gh_project_url##*/}" + curl -L "${gh_project_url}/archive/${tarfile}" -o "${tarfile}" + + # unzip it and compare it with the tar we would generate + if ! cmp --silent <(gunzip -c $tarfile) \ + <(git archive --format tar --prefix="${project_name}-${release}/" "$tag"); then + + # we don't bail out here, because really it's more likely that our comparison + # screwed up and it's super annoying to abort the script at this point. + cat >&2 < "${release_text}" +echo >> "${release_text}" +cat "${latest_changes}" >> "${release_text}" +hub release create $hubflags $assets -F "${release_text}" "$tag" + +if [ $dodist -eq 0 ]; then + rm -rf "$builddir" +fi +rm "${release_text}" +rm "${latest_changes}" + +# Login and publish continues to use `npm`, as it seems to have more clearly +# defined options and semantics than `yarn` for writing to the registry. +npm publish + +if [ -z "$skip_jsdoc" ]; then + echo "generating jsdocs" + yarn gendoc + + echo "copying jsdocs to gh-pages branch" + git checkout gh-pages + git pull + cp -a ".jsdoc/matrix-js-sdk/$release" . + perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print + "
  • Version ${rel}
  • \n"' \ + $release index.html + git add "$release" + git commit --no-verify -m "Add jsdoc for $release" index.html "$release" +fi + +# if it is a pre-release, leave it on the release branch for now. +if [ $prerelease -eq 1 ]; then + git checkout "$rel_branch" + exit 0 +fi + +# merge release branch to master +echo "updating master branch" +git checkout master +git pull +git merge "$rel_branch" + +# push master and docs (if generated) to github +git push origin master +if [ -z "$skip_jsdoc" ]; then + git push origin gh-pages +fi + +# finally, merge master back onto develop +git checkout develop +git pull +git merge master +git push origin develop diff --git a/matrix-js-sdk/scripts/changelog_head.py b/matrix-js-sdk/scripts/changelog_head.py new file mode 100755 index 000000000..c7c1c0aea --- /dev/null +++ b/matrix-js-sdk/scripts/changelog_head.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python + +""" +Outputs the body of the first entry of changelog file on stdin +""" + +import re +import sys + +found_first_header = False +for line in sys.stdin: + line = line.strip() + if re.match(r"^Changes in \[.*\]", line): + if found_first_header: + break + found_first_header = True + elif not re.match(r"^=+$", line) and len(line) > 0: + print line diff --git a/matrix-js-sdk/spec/.eslintrc.js b/matrix-js-sdk/spec/.eslintrc.js new file mode 100644 index 000000000..4cc4659d7 --- /dev/null +++ b/matrix-js-sdk/spec/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + env: { + mocha: true, + }, +} diff --git a/matrix-js-sdk/spec/MockStorageApi.js b/matrix-js-sdk/spec/MockStorageApi.js new file mode 100644 index 000000000..1063ad3db --- /dev/null +++ b/matrix-js-sdk/spec/MockStorageApi.js @@ -0,0 +1,56 @@ +/* +Copyright 2015, 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. +*/ + +/** + * A mock implementation of the webstorage api + * @constructor + */ +function MockStorageApi() { + this.data = {}; + this.keys = []; + this.length = 0; +} + +MockStorageApi.prototype = { + setItem: function(k, v) { + this.data[k] = v; + this._recalc(); + }, + getItem: function(k) { + return this.data[k] || null; + }, + removeItem: function(k) { + delete this.data[k]; + this._recalc(); + }, + key: function(index) { + return this.keys[index]; + }, + _recalc: function() { + const keys = []; + for (const k in this.data) { + if (!this.data.hasOwnProperty(k)) { + continue; + } + keys.push(k); + } + this.keys = keys; + this.length = keys.length; + }, +}; + +/** */ +module.exports = MockStorageApi; diff --git a/matrix-js-sdk/spec/TestClient.js b/matrix-js-sdk/spec/TestClient.js new file mode 100644 index 000000000..5af5c30f4 --- /dev/null +++ b/matrix-js-sdk/spec/TestClient.js @@ -0,0 +1,234 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018-2019 New Vector 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. +*/ + +"use strict"; + +// load olm before the sdk if possible +import './olm-loader'; + +import sdk from '..'; +import testUtils from './test-utils'; +import MockHttpBackend from 'matrix-mock-request'; +import expect from 'expect'; +import Promise from 'bluebird'; +import LocalStorageCryptoStore from '../lib/crypto/store/localStorage-crypto-store'; +import logger from '../src/logger'; + +/** + * Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient + * + * @constructor + * @param {string} userId + * @param {string} deviceId + * @param {string} accessToken + * + * @param {WebStorage=} sessionStoreBackend a web storage object to use for the + * session store. If undefined, we will create a MockStorageApi. + * @param {object} options additional options to pass to the client + */ +export default function TestClient( + userId, deviceId, accessToken, sessionStoreBackend, options, +) { + this.userId = userId; + this.deviceId = deviceId; + + if (sessionStoreBackend === undefined) { + sessionStoreBackend = new testUtils.MockStorageApi(); + } + const sessionStore = new sdk.WebStorageSessionStore(sessionStoreBackend); + + this.httpBackend = new MockHttpBackend(); + + options = Object.assign({ + baseUrl: "http://" + userId + ".test.server", + userId: userId, + accessToken: accessToken, + deviceId: deviceId, + sessionStore: sessionStore, + request: this.httpBackend.requestFn, + }, options); + if (!options.cryptoStore) { + // expose this so the tests can get to it + this.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); + options.cryptoStore = this.cryptoStore; + } + this.client = sdk.createClient(options); + + this.deviceKeys = null; + this.oneTimeKeys = {}; +} + +TestClient.prototype.toString = function() { + return 'TestClient[' + this.userId + ']'; +}; + +/** + * start the client, and wait for it to initialise. + * + * @return {Promise} + */ +TestClient.prototype.start = function() { + logger.log(this + ': starting'); + this.httpBackend.when("GET", "/pushrules").respond(200, {}); + this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); + this.expectDeviceKeyUpload(); + + // we let the client do a very basic initial sync, which it needs before + // it will upload one-time keys. + this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 }); + + this.client.startClient({ + // set this so that we can get hold of failed events + pendingEventOrdering: 'detached', + }); + + return Promise.all([ + this.httpBackend.flushAllExpected(), + testUtils.syncPromise(this.client), + ]).then(() => { + logger.log(this + ': started'); + }); +}; + +/** + * stop the client + * @return {Promise} Resolves once the mock http backend has finished all pending flushes + */ +TestClient.prototype.stop = function() { + this.client.stopClient(); + return this.httpBackend.stop(); +}; + +/** + * Set up expectations that the client will upload device keys. + */ +TestClient.prototype.expectDeviceKeyUpload = function() { + const self = this; + this.httpBackend.when("POST", "/keys/upload").respond(200, function(path, content) { + expect(content.one_time_keys).toBe(undefined); + expect(content.device_keys).toBeTruthy(); + + logger.log(self + ': received device keys'); + // we expect this to happen before any one-time keys are uploaded. + expect(Object.keys(self.oneTimeKeys).length).toEqual(0); + + self.deviceKeys = content.device_keys; + return {one_time_key_counts: {signed_curve25519: 0}}; + }); +}; + + +/** + * If one-time keys have already been uploaded, return them. Otherwise, + * set up an expectation that the keys will be uploaded, and wait for + * that to happen. + * + * @returns {Promise} for the one-time keys + */ +TestClient.prototype.awaitOneTimeKeyUpload = function() { + if (Object.keys(this.oneTimeKeys).length != 0) { + // already got one-time keys + return Promise.resolve(this.oneTimeKeys); + } + + this.httpBackend.when("POST", "/keys/upload") + .respond(200, (path, content) => { + expect(content.device_keys).toBe(undefined); + expect(content.one_time_keys).toBe(undefined); + return {one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys).length, + }}; + }); + + this.httpBackend.when("POST", "/keys/upload") + .respond(200, (path, content) => { + expect(content.device_keys).toBe(undefined); + expect(content.one_time_keys).toBeTruthy(); + expect(content.one_time_keys).toNotEqual({}); + logger.log('%s: received %i one-time keys', this, + Object.keys(content.one_time_keys).length); + this.oneTimeKeys = content.one_time_keys; + return {one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys).length, + }}; + }); + + // this can take ages + return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => { + expect(flushed).toEqual(2); + return this.oneTimeKeys; + }); +}; + +/** + * Set up expectations that the client will query device keys. + * + * We check that the query contains each of the users in `response`. + * + * @param {Object} response response to the query. + */ +TestClient.prototype.expectKeyQuery = function(response) { + this.httpBackend.when('POST', '/keys/query').respond( + 200, (path, content) => { + Object.keys(response.device_keys).forEach((userId) => { + expect(content.device_keys[userId]).toEqual( + [], + "Expected key query for " + userId + ", got " + + Object.keys(content.device_keys), + ); + }); + return response; + }); +}; + + +/** + * get the uploaded curve25519 device key + * + * @return {string} base64 device key + */ +TestClient.prototype.getDeviceKey = function() { + const keyId = 'curve25519:' + this.deviceId; + return this.deviceKeys.keys[keyId]; +}; + + +/** + * get the uploaded ed25519 device key + * + * @return {string} base64 device key + */ +TestClient.prototype.getSigningKey = function() { + const keyId = 'ed25519:' + this.deviceId; + return this.deviceKeys.keys[keyId]; +}; + +/** + * flush a single /sync request, and wait for the syncing event + * + * @returns {Promise} promise which completes once the sync has been flushed + */ +TestClient.prototype.flushSync = function() { + logger.log(`${this}: flushSync`); + return Promise.all([ + this.httpBackend.flush('/sync', 1), + testUtils.syncPromise(this.client), + ]).then(() => { + logger.log(`${this}: flushSync completed`); + }); +}; diff --git a/matrix-js-sdk/spec/integ/devicelist-integ-spec.js b/matrix-js-sdk/spec/integ/devicelist-integ-spec.js new file mode 100644 index 000000000..ccea3e43c --- /dev/null +++ b/matrix-js-sdk/spec/integ/devicelist-integ-spec.js @@ -0,0 +1,403 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ + +import expect from 'expect'; +import Promise from 'bluebird'; + +import TestClient from '../TestClient'; +import testUtils from '../test-utils'; +import logger from '../../src/logger'; + +const ROOM_ID = "!room:id"; + +/** + * get a /sync response which contains a single e2e room (ROOM_ID), with the + * members given + * + * @param {string[]} roomMembers + * + * @return {object} sync response + */ +function getSyncResponse(roomMembers) { + const stateEvents = [ + testUtils.mkEvent({ + type: 'm.room.encryption', + skey: '', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + }, + }), + ]; + + Array.prototype.push.apply( + stateEvents, + roomMembers.map( + (m) => testUtils.mkMembership({ + mship: 'join', + sender: m, + }), + ), + ); + + const syncResponse = { + next_batch: 1, + rooms: { + join: { + [ROOM_ID]: { + state: { + events: stateEvents, + }, + }, + }, + }, + }; + + return syncResponse; +} + + +describe("DeviceList management:", function() { + if (!global.Olm) { + logger.warn('not running deviceList tests: Olm not present'); + return; + } + + let sessionStoreBackend; + let aliceTestClient; + + async function createTestClient() { + const testClient = new TestClient( + "@alice:localhost", "xzcvb", "akjgkrgjs", sessionStoreBackend, + ); + await testClient.client.initCrypto(); + return testClient; + } + + beforeEach(async function() { + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + + // we create our own sessionStoreBackend so that we can use it for + // another TestClient. + sessionStoreBackend = new testUtils.MockStorageApi(); + + aliceTestClient = await createTestClient(); + }); + + afterEach(function() { + return aliceTestClient.stop(); + }); + + it("Alice shouldn't do a second /query for non-e2e-capable devices", function() { + aliceTestClient.expectKeyQuery({device_keys: {'@alice:localhost': {}}}); + return aliceTestClient.start().then(function() { + const syncResponse = getSyncResponse(['@bob:xyz']); + aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + + return aliceTestClient.flushSync(); + }).then(function() { + logger.log("Forcing alice to download our device keys"); + + aliceTestClient.httpBackend.when('POST', '/keys/query').respond(200, { + device_keys: { + '@bob:xyz': [], + }, + }); + + return Promise.all([ + aliceTestClient.client.downloadKeys(['@bob:xyz']), + aliceTestClient.httpBackend.flush('/keys/query', 1), + ]); + }).then(function() { + logger.log("Telling alice to send a megolm message"); + + aliceTestClient.httpBackend.when( + 'PUT', '/send/', + ).respond(200, { + event_id: '$event_id', + }); + + return Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), + + // the crypto stuff can take a while, so give the requests a whole second. + aliceTestClient.httpBackend.flushAllExpected({ + timeout: 1000, + }), + ]); + }); + }); + + + it("We should not get confused by out-of-order device query responses", + () => { + // https://github.com/vector-im/riot-web/issues/3126 + aliceTestClient.expectKeyQuery({device_keys: {'@alice:localhost': []}}); + return aliceTestClient.start().then(() => { + aliceTestClient.httpBackend.when('GET', '/sync').respond( + 200, getSyncResponse(['@bob:xyz', '@chris:abc'])); + return aliceTestClient.flushSync(); + }).then(() => { + // to make sure the initial device queries are flushed out, we + // attempt to send a message. + + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, { + device_keys: { + '@bob:xyz': [], + '@chris:abc': [], + }, + }, + ); + + aliceTestClient.httpBackend.when('PUT', '/send/').respond( + 200, {event_id: '$event1'}); + + return Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), + aliceTestClient.httpBackend.flush('/keys/query', 1).then( + () => aliceTestClient.httpBackend.flush('/send/', 1), + ), + aliceTestClient.client._crypto._deviceList.saveIfDirty(), + ]); + }).then(() => { + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + expect(data.syncToken).toEqual(1); + }); + + // invalidate bob's and chris's device lists in separate syncs + aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { + next_batch: '2', + device_lists: { + changed: ['@bob:xyz'], + }, + }); + aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { + next_batch: '3', + device_lists: { + changed: ['@chris:abc'], + }, + }); + // flush both syncs + return aliceTestClient.flushSync().then(() => { + return aliceTestClient.flushSync(); + }); + }).then(() => { + // check that we don't yet have a request for chris's devices. + aliceTestClient.httpBackend.when('POST', '/keys/query', { + device_keys: { + '@chris:abc': [], + }, + token: '3', + }).respond(200, { + device_keys: {'@chris:abc': []}, + }); + return aliceTestClient.httpBackend.flush('/keys/query', 1); + }).then((flushed) => { + expect(flushed).toEqual(0); + return aliceTestClient.client._crypto._deviceList.saveIfDirty(); + }).then(() => { + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + if (bobStat != 1 && bobStat != 2) { + throw new Error('Unexpected status for bob: wanted 1 or 2, got ' + + bobStat); + } + const chrisStat = data.trackingStatus['@chris:abc']; + if (chrisStat != 1 && chrisStat != 2) { + throw new Error( + 'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat, + ); + } + }); + + // now add an expectation for a query for bob's devices, and let + // it complete. + aliceTestClient.httpBackend.when('POST', '/keys/query', { + device_keys: { + '@bob:xyz': [], + }, + token: '2', + }).respond(200, { + device_keys: {'@bob:xyz': []}, + }); + return aliceTestClient.httpBackend.flush('/keys/query', 1); + }).then((flushed) => { + expect(flushed).toEqual(1); + + // wait for the client to stop processing the response + return aliceTestClient.client.downloadKeys(['@bob:xyz']); + }).then(() => { + return aliceTestClient.client._crypto._deviceList.saveIfDirty(); + }).then(() => { + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + expect(bobStat).toEqual(3); + const chrisStat = data.trackingStatus['@chris:abc']; + if (chrisStat != 1 && chrisStat != 2) { + throw new Error( + 'Unexpected status for chris: wanted 1 or 2, got ' + bobStat, + ); + } + }); + + // now let the query for chris's devices complete. + return aliceTestClient.httpBackend.flush('/keys/query', 1); + }).then((flushed) => { + expect(flushed).toEqual(1); + + // wait for the client to stop processing the response + return aliceTestClient.client.downloadKeys(['@chris:abc']); + }).then(() => { + return aliceTestClient.client._crypto._deviceList.saveIfDirty(); + }).then(() => { + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + const chrisStat = data.trackingStatus['@bob:xyz']; + + expect(bobStat).toEqual(3); + expect(chrisStat).toEqual(3); + expect(data.syncToken).toEqual(3); + }); + }); + }).timeout(3000); + + // https://github.com/vector-im/riot-web/issues/4983 + describe("Alice should know she has stale device lists", () => { + beforeEach(async function() { + await aliceTestClient.start(); + + aliceTestClient.httpBackend.when('GET', '/sync').respond( + 200, getSyncResponse(['@bob:xyz'])); + await aliceTestClient.flushSync(); + + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, { + device_keys: { + '@bob:xyz': [], + }, + }, + ); + await aliceTestClient.httpBackend.flush('/keys/query', 1); + await aliceTestClient.client._crypto._deviceList.saveIfDirty(); + + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + + expect(bobStat).toBeGreaterThan( + 0, "Alice should be tracking bob's device list", + ); + }); + }); + + it("when Bob leaves", async function() { + aliceTestClient.httpBackend.when('GET', '/sync').respond( + 200, { + next_batch: 2, + device_lists: { + left: ['@bob:xyz'], + }, + rooms: { + join: { + [ROOM_ID]: { + timeline: { + events: [ + testUtils.mkMembership({ + mship: 'leave', + sender: '@bob:xyz', + }), + ], + }, + }, + }, + }, + }, + ); + + + await aliceTestClient.flushSync(); + await aliceTestClient.client._crypto._deviceList.saveIfDirty(); + + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + + expect(bobStat).toEqual( + 0, "Alice should have marked bob's device list as untracked", + ); + }); + }); + + it("when Alice leaves", async function() { + aliceTestClient.httpBackend.when('GET', '/sync').respond( + 200, { + next_batch: 2, + device_lists: { + left: ['@bob:xyz'], + }, + rooms: { + leave: { + [ROOM_ID]: { + timeline: { + events: [ + testUtils.mkMembership({ + mship: 'leave', + sender: '@bob:xyz', + }), + ], + }, + }, + }, + }, + }, + ); + + await aliceTestClient.flushSync(); + await aliceTestClient.client._crypto._deviceList.saveIfDirty(); + + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + + expect(bobStat).toEqual( + 0, "Alice should have marked bob's device list as untracked", + ); + }); + }); + + it("when Bob leaves whilst Alice is offline", async function() { + aliceTestClient.stop(); + + const anotherTestClient = await createTestClient(); + + try { + await anotherTestClient.start(); + anotherTestClient.httpBackend.when('GET', '/sync').respond( + 200, getSyncResponse([])); + await anotherTestClient.flushSync(); + await anotherTestClient.client._crypto._deviceList.saveIfDirty(); + + anotherTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + + expect(bobStat).toEqual( + 0, "Alice should have marked bob's device list as untracked", + ); + }); + } finally { + anotherTestClient.stop(); + } + }); + }); +}); diff --git a/matrix-js-sdk/spec/integ/matrix-client-crypto.spec.js b/matrix-js-sdk/spec/integ/matrix-client-crypto.spec.js new file mode 100644 index 000000000..718659cb3 --- /dev/null +++ b/matrix-js-sdk/spec/integ/matrix-client-crypto.spec.js @@ -0,0 +1,771 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ + +/* This file consists of a set of integration tests which try to simulate + * communication via an Olm-encrypted room between two users, Alice and Bob. + * + * Note that megolm (group) conversation is not tested here. + * + * See also `megolm.spec.js`. + */ + +"use strict"; +import 'source-map-support/register'; + +// load olm before the sdk if possible +import '../olm-loader'; + +import expect from 'expect'; +const sdk = require("../.."); +import Promise from 'bluebird'; +const utils = require("../../lib/utils"); +const testUtils = require("../test-utils"); +const TestClient = require('../TestClient').default; +import logger from '../../src/logger'; + +let aliTestClient; +const roomId = "!room:localhost"; +const aliUserId = "@ali:localhost"; +const aliDeviceId = "zxcvb"; +const aliAccessToken = "aseukfgwef"; +let bobTestClient; +const bobUserId = "@bob:localhost"; +const bobDeviceId = "bvcxz"; +const bobAccessToken = "fewgfkuesa"; +let aliMessages; +let bobMessages; + +function bobUploadsDeviceKeys() { + bobTestClient.expectDeviceKeyUpload(); + return Promise.all([ + bobTestClient.client.uploadKeys(), + bobTestClient.httpBackend.flush(), + ]).then(() => { + expect(Object.keys(bobTestClient.deviceKeys).length).toNotEqual(0); + }); +} + +/** + * Set an expectation that ali will query bobs keys; then flush the http request. + * + * @return {promise} resolves once the http request has completed. + */ +function expectAliQueryKeys() { + // can't query keys before bob has uploaded them + expect(bobTestClient.deviceKeys).toBeTruthy(); + + const bobKeys = {}; + bobKeys[bobDeviceId] = bobTestClient.deviceKeys; + aliTestClient.httpBackend.when("POST", "/keys/query") + .respond(200, function(path, content) { + expect(content.device_keys[bobUserId]).toEqual( + [], + "Expected Alice to key query for " + bobUserId + ", got " + + Object.keys(content.device_keys), + ); + const result = {}; + result[bobUserId] = bobKeys; + return {device_keys: result}; + }); + return aliTestClient.httpBackend.flush("/keys/query", 1); +} + +/** + * Set an expectation that bob will query alis keys; then flush the http request. + * + * @return {promise} which resolves once the http request has completed. + */ +function expectBobQueryKeys() { + // can't query keys before ali has uploaded them + expect(aliTestClient.deviceKeys).toBeTruthy(); + + const aliKeys = {}; + aliKeys[aliDeviceId] = aliTestClient.deviceKeys; + logger.log("query result will be", aliKeys); + + bobTestClient.httpBackend.when( + "POST", "/keys/query", + ).respond(200, function(path, content) { + expect(content.device_keys[aliUserId]).toEqual( + [], + "Expected Bob to key query for " + aliUserId + ", got " + + Object.keys(content.device_keys), + ); + const result = {}; + result[aliUserId] = aliKeys; + return {device_keys: result}; + }); + return bobTestClient.httpBackend.flush("/keys/query", 1); +} + +/** + * Set an expectation that ali will claim one of bob's keys; then flush the http request. + * + * @return {promise} resolves once the http request has completed. + */ +function expectAliClaimKeys() { + return bobTestClient.awaitOneTimeKeyUpload().then((keys) => { + aliTestClient.httpBackend.when( + "POST", "/keys/claim", + ).respond(200, function(path, content) { + const claimType = content.one_time_keys[bobUserId][bobDeviceId]; + expect(claimType).toEqual("signed_curve25519"); + let keyId = null; + for (keyId in keys) { + if (bobTestClient.oneTimeKeys.hasOwnProperty(keyId)) { + if (keyId.indexOf(claimType + ":") === 0) { + break; + } + } + } + const result = {}; + result[bobUserId] = {}; + result[bobUserId][bobDeviceId] = {}; + result[bobUserId][bobDeviceId][keyId] = keys[keyId]; + return {one_time_keys: result}; + }); + }).then(() => { + // it can take a while to process the key query, so give it some extra + // time, and make sure the claim actually happens rather than ploughing on + // confusingly. + return aliTestClient.httpBackend.flush("/keys/claim", 1, 500).then((r) => { + expect(r).toEqual(1, "Ali did not claim Bob's keys"); + }); + }); +} + + +function aliDownloadsKeys() { + // can't query keys before bob has uploaded them + expect(bobTestClient.getSigningKey()).toBeTruthy(); + + const p1 = aliTestClient.client.downloadKeys([bobUserId]).then(function() { + return aliTestClient.client.getStoredDevicesForUser(bobUserId); + }).then((devices) => { + expect(devices.length).toEqual(1); + expect(devices[0].deviceId).toEqual("bvcxz"); + }); + const p2 = expectAliQueryKeys(); + + // 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(); + }).then(() => { + aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const devices = data.devices[bobUserId]; + expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys); + expect(devices[bobDeviceId].verified). + toBe(0); // DeviceVerification.UNVERIFIED + }); + }); +} + +function aliEnablesEncryption() { + return aliTestClient.client.setRoomEncryption(roomId, { + algorithm: "m.olm.v1.curve25519-aes-sha2", + }).then(function() { + expect(aliTestClient.client.isRoomEncrypted(roomId)).toBeTruthy(); + }); +} + +function bobEnablesEncryption() { + return bobTestClient.client.setRoomEncryption(roomId, { + algorithm: "m.olm.v1.curve25519-aes-sha2", + }).then(function() { + expect(bobTestClient.client.isRoomEncrypted(roomId)).toBeTruthy(); + }); +} + +/** + * Ali sends a message, first claiming e2e keys. Set the expectations and + * check the results. + * + * @return {promise} which resolves to the ciphertext for Bob's device. + */ +function aliSendsFirstMessage() { + return Promise.all([ + sendMessage(aliTestClient.client), + expectAliQueryKeys() + .then(expectAliClaimKeys) + .then(expectAliSendMessageRequest), + ]).spread(function(_, ciphertext) { + return ciphertext; + }); +} + +/** + * Ali sends a message without first claiming e2e keys. Set the expectations + * and check the results. + * + * @return {promise} which resolves to the ciphertext for Bob's device. + */ +function aliSendsMessage() { + return Promise.all([ + sendMessage(aliTestClient.client), + expectAliSendMessageRequest(), + ]).spread(function(_, ciphertext) { + return ciphertext; + }); +} + +/** + * Bob sends a message, first querying (but not claiming) e2e keys. Set the + * expectations and check the results. + * + * @return {promise} which resolves to the ciphertext for Ali's device. + */ +function bobSendsReplyMessage() { + return Promise.all([ + sendMessage(bobTestClient.client), + expectBobQueryKeys() + .then(expectBobSendMessageRequest), + ]).spread(function(_, ciphertext) { + return ciphertext; + }); +} + +/** + * Set an expectation that Ali will send a message, and flush the request + * + * @return {promise} which resolves to the ciphertext for Bob's device. + */ +function expectAliSendMessageRequest() { + return expectSendMessageRequest(aliTestClient.httpBackend).then(function(content) { + aliMessages.push(content); + expect(utils.keys(content.ciphertext)).toEqual([bobTestClient.getDeviceKey()]); + const ciphertext = content.ciphertext[bobTestClient.getDeviceKey()]; + expect(ciphertext).toBeTruthy(); + return ciphertext; + }); +} + +/** + * Set an expectation that Bob will send a message, and flush the request + * + * @return {promise} which resolves to the ciphertext for Bob's device. + */ +function expectBobSendMessageRequest() { + return expectSendMessageRequest(bobTestClient.httpBackend).then(function(content) { + bobMessages.push(content); + const aliKeyId = "curve25519:" + aliDeviceId; + const aliDeviceCurve25519Key = aliTestClient.deviceKeys.keys[aliKeyId]; + expect(utils.keys(content.ciphertext)).toEqual([aliDeviceCurve25519Key]); + const ciphertext = content.ciphertext[aliDeviceCurve25519Key]; + expect(ciphertext).toBeTruthy(); + return ciphertext; + }); +} + +function sendMessage(client) { + return client.sendMessage( + roomId, {msgtype: "m.text", body: "Hello, World"}, + ); +} + +function expectSendMessageRequest(httpBackend) { + const path = "/send/m.room.encrypted/"; + const deferred = Promise.defer(); + httpBackend.when("PUT", path).respond(200, function(path, content) { + deferred.resolve(content); + return { + event_id: "asdfgh", + }; + }); + + // it can take a while to process the key query + return httpBackend.flush(path, 1).then(() => deferred.promise); +} + +function aliRecvMessage() { + const message = bobMessages.shift(); + return recvMessage( + aliTestClient.httpBackend, aliTestClient.client, bobUserId, message, + ); +} + +function bobRecvMessage() { + const message = aliMessages.shift(); + return recvMessage( + bobTestClient.httpBackend, bobTestClient.client, aliUserId, message, + ); +} + +function recvMessage(httpBackend, client, sender, message) { + const syncData = { + next_batch: "x", + rooms: { + join: { + + }, + }, + }; + syncData.rooms.join[roomId] = { + timeline: { + events: [ + testUtils.mkEvent({ + type: "m.room.encrypted", + room: roomId, + content: message, + sender: sender, + }), + ], + }, + }; + httpBackend.when("GET", "/sync").respond(200, syncData); + + const eventPromise = new Promise((resolve, reject) => { + const onEvent = function(event) { + // ignore the m.room.member events + if (event.getType() == "m.room.member") { + return; + } + logger.log(client.credentials.userId + " received event", + event); + + client.removeListener("event", onEvent); + resolve(event); + }; + client.on("event", onEvent); + }); + + httpBackend.flush(); + + return eventPromise.then((event) => { + expect(event.isEncrypted()).toBeTruthy(); + + // it may still be being decrypted + return testUtils.awaitDecryption(event); + }).then((event) => { + expect(event.getType()).toEqual("m.room.message"); + expect(event.getContent()).toEqual({ + msgtype: "m.text", + body: "Hello, World", + }); + expect(event.isEncrypted()).toBeTruthy(); + }); +} + + +/** + * Send an initial sync response to the client (which just includes the member + * list for our test room). + * + * @param {TestClient} testClient + * @returns {Promise} which resolves when the sync has been flushed. + */ +function firstSync(testClient) { + // send a sync response including our test room. + const syncData = { + next_batch: "x", + rooms: { + join: { }, + }, + }; + syncData.rooms.join[roomId] = { + state: { + events: [ + testUtils.mkMembership({ + mship: "join", + user: aliUserId, + }), + testUtils.mkMembership({ + mship: "join", + user: bobUserId, + }), + ], + }, + timeline: { + events: [], + }, + }; + + testClient.httpBackend.when("GET", "/sync").respond(200, syncData); + return testClient.flushSync(); +} + + +describe("MatrixClient crypto", function() { + if (!sdk.CRYPTO_ENABLED) { + return; + } + + beforeEach(async function() { + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + + aliTestClient = new TestClient(aliUserId, aliDeviceId, aliAccessToken); + await aliTestClient.client.initCrypto(); + + bobTestClient = new TestClient(bobUserId, bobDeviceId, bobAccessToken); + await bobTestClient.client.initCrypto(); + + aliMessages = []; + bobMessages = []; + }); + + afterEach(function() { + aliTestClient.httpBackend.verifyNoOutstandingExpectation(); + bobTestClient.httpBackend.verifyNoOutstandingExpectation(); + + return Promise.all([aliTestClient.stop(), bobTestClient.stop()]); + }); + + it("Bob uploads device keys", function() { + return Promise.resolve() + .then(bobUploadsDeviceKeys); + }); + + it("Ali downloads Bobs device keys", function(done) { + Promise.resolve() + .then(bobUploadsDeviceKeys) + .then(aliDownloadsKeys) + .nodeify(done); + }); + + it("Ali gets keys with an invalid signature", function(done) { + Promise.resolve() + .then(bobUploadsDeviceKeys) + .then(function() { + // tamper bob's keys + const bobDeviceKeys = bobTestClient.deviceKeys; + expect(bobDeviceKeys.keys["curve25519:" + bobDeviceId]).toBeTruthy(); + bobDeviceKeys.keys["curve25519:" + bobDeviceId] += "abc"; + + return Promise.all([ + aliTestClient.client.downloadKeys([bobUserId]), + expectAliQueryKeys(), + ]); + }).then(function() { + return aliTestClient.client.getStoredDevicesForUser(bobUserId); + }).then((devices) => { + // should get an empty list + expect(devices).toEqual([]); + }) + .nodeify(done); + }); + + it("Ali gets keys with an incorrect userId", function(done) { + const eveUserId = "@eve:localhost"; + + const bobDeviceKeys = { + algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'], + device_id: 'bvcxz', + keys: { + 'ed25519:bvcxz': 'pYuWKMCVuaDLRTM/eWuB8OlXEb61gZhfLVJ+Y54tl0Q', + 'curve25519:bvcxz': '7Gni0loo/nzF0nFp9847RbhElGewzwUXHPrljjBGPTQ', + }, + user_id: '@eve:localhost', + signatures: { + '@eve:localhost': { + 'ed25519:bvcxz': 'CliUPZ7dyVPBxvhSA1d+X+LYa5b2AYdjcTwG' + + '0stXcIxjaJNemQqtdgwKDtBFl3pN2I13SEijRDCf1A8bYiQMDg', + }, + }, + }; + + const bobKeys = {}; + bobKeys[bobDeviceId] = bobDeviceKeys; + aliTestClient.httpBackend.when( + "POST", "/keys/query", + ).respond(200, function(path, content) { + const result = {}; + result[bobUserId] = bobKeys; + return {device_keys: result}; + }); + + Promise.all([ + aliTestClient.client.downloadKeys([bobUserId, eveUserId]), + aliTestClient.httpBackend.flush("/keys/query", 1), + ]).then(function() { + return Promise.all([ + aliTestClient.client.getStoredDevicesForUser(bobUserId), + aliTestClient.client.getStoredDevicesForUser(eveUserId), + ]); + }).spread((bobDevices, eveDevices) => { + // should get an empty list + expect(bobDevices).toEqual([]); + expect(eveDevices).toEqual([]); + }).nodeify(done); + }); + + it("Ali gets keys with an incorrect deviceId", function(done) { + const bobDeviceKeys = { + algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'], + device_id: 'bad_device', + keys: { + 'ed25519:bad_device': 'e8XlY5V8x2yJcwa5xpSzeC/QVOrU+D5qBgyTK0ko+f0', + 'curve25519:bad_device': 'YxuuLG/4L5xGeP8XPl5h0d7DzyYVcof7J7do+OXz0xc', + }, + user_id: '@bob:localhost', + signatures: { + '@bob:localhost': { + 'ed25519:bad_device': 'fEFTq67RaSoIEVBJ8DtmRovbwUBKJ0A' + + 'me9m9PDzM9azPUwZ38Xvf6vv1A7W1PSafH4z3Y2ORIyEnZgHaNby3CQ', + }, + }, + }; + + const bobKeys = {}; + bobKeys[bobDeviceId] = bobDeviceKeys; + aliTestClient.httpBackend.when( + "POST", "/keys/query", + ).respond(200, function(path, content) { + const result = {}; + result[bobUserId] = bobKeys; + return {device_keys: result}; + }); + + Promise.all([ + aliTestClient.client.downloadKeys([bobUserId]), + aliTestClient.httpBackend.flush("/keys/query", 1), + ]).then(function() { + return aliTestClient.client.getStoredDevicesForUser(bobUserId); + }).then((devices) => { + // should get an empty list + expect(devices).toEqual([]); + }).nodeify(done); + }); + + + it("Bob starts his client and uploads device keys and one-time keys", function() { + return Promise.resolve() + .then(() => bobTestClient.start()) + .then(() => bobTestClient.awaitOneTimeKeyUpload()) + .then((keys) => { + expect(Object.keys(keys).length).toEqual(5); + expect(Object.keys(bobTestClient.deviceKeys).length).toNotEqual(0); + }); + }); + + it("Ali sends a message", function(done) { + aliTestClient.expectKeyQuery({device_keys: {[aliUserId]: {}}}); + Promise.resolve() + .then(() => aliTestClient.start()) + .then(() => bobTestClient.start()) + .then(() => firstSync(aliTestClient)) + .then(aliEnablesEncryption) + .then(aliSendsFirstMessage) + .nodeify(done); + }); + + it("Bob receives a message", function() { + aliTestClient.expectKeyQuery({device_keys: {[aliUserId]: {}}}); + return Promise.resolve() + .then(() => aliTestClient.start()) + .then(() => bobTestClient.start()) + .then(() => firstSync(aliTestClient)) + .then(aliEnablesEncryption) + .then(aliSendsFirstMessage) + .then(bobRecvMessage); + }); + + it("Bob receives a message with a bogus sender", function() { + aliTestClient.expectKeyQuery({device_keys: {[aliUserId]: {}}}); + return Promise.resolve() + .then(() => aliTestClient.start()) + .then(() => bobTestClient.start()) + .then(() => firstSync(aliTestClient)) + .then(aliEnablesEncryption) + .then(aliSendsFirstMessage) + .then(function() { + const message = aliMessages.shift(); + const syncData = { + next_batch: "x", + rooms: { + join: { + + }, + }, + }; + syncData.rooms.join[roomId] = { + timeline: { + events: [ + testUtils.mkEvent({ + type: "m.room.encrypted", + room: roomId, + content: message, + sender: "@bogus:sender", + }), + ], + }, + }; + bobTestClient.httpBackend.when("GET", "/sync").respond(200, syncData); + + const eventPromise = new Promise((resolve, reject) => { + const onEvent = function(event) { + logger.log(bobUserId + " received event", + event); + resolve(event); + }; + bobTestClient.client.once("event", onEvent); + }); + + bobTestClient.httpBackend.flush(); + return eventPromise; + }).then((event) => { + expect(event.isEncrypted()).toBeTruthy(); + + // it may still be being decrypted + return testUtils.awaitDecryption(event); + }).then((event) => { + expect(event.getType()).toEqual("m.room.message"); + expect(event.getContent().msgtype).toEqual("m.bad.encrypted"); + }); + }); + + it("Ali blocks Bob's device", function(done) { + aliTestClient.expectKeyQuery({device_keys: {[aliUserId]: {}}}); + Promise.resolve() + .then(() => aliTestClient.start()) + .then(() => bobTestClient.start()) + .then(() => firstSync(aliTestClient)) + .then(aliEnablesEncryption) + .then(aliDownloadsKeys) + .then(function() { + aliTestClient.client.setDeviceBlocked(bobUserId, bobDeviceId, true); + const p1 = sendMessage(aliTestClient.client); + const p2 = expectSendMessageRequest(aliTestClient.httpBackend) + .then(function(sentContent) { + // no unblocked devices, so the ciphertext should be empty + expect(sentContent.ciphertext).toEqual({}); + }); + return Promise.all([p1, p2]); + }).nodeify(done); + }); + + it("Bob receives two pre-key messages", function(done) { + aliTestClient.expectKeyQuery({device_keys: {[aliUserId]: {}}}); + Promise.resolve() + .then(() => aliTestClient.start()) + .then(() => bobTestClient.start()) + .then(() => firstSync(aliTestClient)) + .then(aliEnablesEncryption) + .then(aliSendsFirstMessage) + .then(bobRecvMessage) + .then(aliSendsMessage) + .then(bobRecvMessage) + .nodeify(done); + }); + + it("Bob replies to the message", function() { + aliTestClient.expectKeyQuery({device_keys: {[aliUserId]: {}}}); + bobTestClient.expectKeyQuery({device_keys: {[bobUserId]: {}}}); + return Promise.resolve() + .then(() => aliTestClient.start()) + .then(() => bobTestClient.start()) + .then(() => firstSync(aliTestClient)) + .then(() => firstSync(bobTestClient)) + .then(aliEnablesEncryption) + .then(aliSendsFirstMessage) + .then(bobRecvMessage) + .then(bobEnablesEncryption) + .then(bobSendsReplyMessage).then(function(ciphertext) { + expect(ciphertext.type).toEqual(1, "Unexpected cipghertext type."); + }).then(aliRecvMessage); + }); + + it("Ali does a key query when encryption is enabled", function() { + // enabling encryption in the room should make alice download devices + // for both members. + aliTestClient.expectKeyQuery({device_keys: {[aliUserId]: {}}}); + return Promise.resolve() + .then(() => aliTestClient.start()) + .then(() => firstSync(aliTestClient)) + .then(() => { + const syncData = { + next_batch: '2', + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomId] = { + state: { + events: [ + testUtils.mkEvent({ + type: 'm.room.encryption', + skey: '', + content: { + algorithm: 'm.olm.v1.curve25519-aes-sha2', + }, + }), + ], + }, + }; + + aliTestClient.httpBackend.when('GET', '/sync').respond( + 200, syncData); + return aliTestClient.httpBackend.flush('/sync', 1); + }).then(() => { + aliTestClient.expectKeyQuery({ + device_keys: { + [bobUserId]: {}, + }, + }); + return aliTestClient.httpBackend.flushAllExpected(); + }); + }); + + it("Upload new oneTimeKeys based on a /sync request - no count-asking", function() { + // Send a response which causes a key upload + const httpBackend = aliTestClient.httpBackend; + const syncDataEmpty = { + next_batch: "a", + device_one_time_keys_count: { + signed_curve25519: 0, + }, + }; + + // enqueue expectations: + // * Sync with empty one_time_keys => upload keys + + return Promise.resolve() + .then(() => { + logger.log(aliTestClient + ': starting'); + httpBackend.when("GET", "/pushrules").respond(200, {}); + httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); + aliTestClient.expectDeviceKeyUpload(); + + // we let the client do a very basic initial sync, which it needs before + // it will upload one-time keys. + httpBackend.when("GET", "/sync").respond(200, syncDataEmpty); + + aliTestClient.client.startClient({}); + + return httpBackend.flushAllExpected().then(() => { + logger.log(aliTestClient + ': started'); + }); + }) + .then(() => httpBackend.when("POST", "/keys/upload") + .respond(200, (path, content) => { + expect(content.one_time_keys).toBeTruthy(); + expect(content.one_time_keys).toNotEqual({}); + expect(Object.keys(content.one_time_keys).length) + .toBeGreaterThanOrEqualTo(1); + logger.log('received %i one-time keys', + Object.keys(content.one_time_keys).length); + // cancel futher calls by telling the client + // we have more than we need + return { + one_time_key_counts: { + signed_curve25519: 70, + }, + }; + })) + .then(() => httpBackend.flushAllExpected()); + }); +}); diff --git a/matrix-js-sdk/spec/integ/matrix-client-event-emitter.spec.js b/matrix-js-sdk/spec/integ/matrix-client-event-emitter.spec.js new file mode 100644 index 000000000..9f7b028eb --- /dev/null +++ b/matrix-js-sdk/spec/integ/matrix-client-event-emitter.spec.js @@ -0,0 +1,342 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const HttpBackend = require("matrix-mock-request"); +const utils = require("../test-utils"); + +import expect from 'expect'; +import Promise from 'bluebird'; + +describe("MatrixClient events", function() { + const baseUrl = "http://localhost.or.something"; + let client; + let httpBackend; + const selfUserId = "@alice:localhost"; + const selfAccessToken = "aseukfgwef"; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + httpBackend = new HttpBackend(); + sdk.request(httpBackend.requestFn); + client = sdk.createClient({ + baseUrl: baseUrl, + userId: selfUserId, + accessToken: selfAccessToken, + }); + httpBackend.when("GET", "/pushrules").respond(200, {}); + httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + }); + + afterEach(function() { + httpBackend.verifyNoOutstandingExpectation(); + client.stopClient(); + return httpBackend.stop(); + }); + + describe("emissions", function() { + const SYNC_DATA = { + next_batch: "s_5_3", + presence: { + events: [ + utils.mkPresence({ + user: "@foo:bar", name: "Foo Bar", presence: "online", + }), + ], + }, + rooms: { + join: { + "!erufh:bar": { + timeline: { + events: [ + utils.mkMessage({ + room: "!erufh:bar", user: "@foo:bar", msg: "hmmm", + }), + ], + prev_batch: "s", + }, + state: { + events: [ + utils.mkMembership({ + room: "!erufh:bar", mship: "join", user: "@foo:bar", + }), + utils.mkEvent({ + type: "m.room.create", room: "!erufh:bar", + user: "@foo:bar", + content: { + creator: "@foo:bar", + }, + }), + ], + }, + }, + }, + }, + }; + const NEXT_SYNC_DATA = { + next_batch: "e_6_7", + rooms: { + join: { + "!erufh:bar": { + timeline: { + events: [ + utils.mkMessage({ + room: "!erufh:bar", user: "@foo:bar", + msg: "ello ello", + }), + utils.mkMessage({ + room: "!erufh:bar", user: "@foo:bar", msg: ":D", + }), + ], + }, + ephemeral: { + events: [ + utils.mkEvent({ + type: "m.typing", room: "!erufh:bar", content: { + user_ids: ["@foo:bar"], + }, + }), + ], + }, + }, + }, + }, + }; + + it("should emit events from both the first and subsequent /sync calls", + function() { + httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + + let expectedEvents = []; + expectedEvents = expectedEvents.concat( + SYNC_DATA.presence.events, + SYNC_DATA.rooms.join["!erufh:bar"].timeline.events, + SYNC_DATA.rooms.join["!erufh:bar"].state.events, + NEXT_SYNC_DATA.rooms.join["!erufh:bar"].timeline.events, + NEXT_SYNC_DATA.rooms.join["!erufh:bar"].ephemeral.events, + ); + + client.on("event", function(event) { + let found = false; + for (let i = 0; i < expectedEvents.length; i++) { + if (expectedEvents[i].event_id === event.getId()) { + expectedEvents.splice(i, 1); + found = true; + break; + } + } + expect(found).toBe( + true, "Unexpected 'event' emitted: " + event.getType(), + ); + }); + + client.startClient(); + + return Promise.all([ + // wait for two SYNCING events + utils.syncPromise(client).then(() => { + return utils.syncPromise(client); + }), + httpBackend.flushAllExpected(), + ]).then(() => { + expect(expectedEvents.length).toEqual( + 0, "Failed to see all events from /sync calls", + ); + }); + }); + + it("should emit User events", function(done) { + httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + let fired = false; + client.on("User.presence", function(event, user) { + fired = true; + expect(user).toBeTruthy(); + expect(event).toBeTruthy(); + if (!user || !event) { + return; + } + + expect(event.event).toMatch(SYNC_DATA.presence.events[0]); + expect(user.presence).toEqual( + SYNC_DATA.presence.events[0].content.presence, + ); + }); + client.startClient(); + + httpBackend.flushAllExpected().done(function() { + expect(fired).toBe(true, "User.presence didn't fire."); + done(); + }); + }); + + it("should emit Room events", function() { + httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + let roomInvokeCount = 0; + let roomNameInvokeCount = 0; + let timelineFireCount = 0; + client.on("Room", function(room) { + roomInvokeCount++; + expect(room.roomId).toEqual("!erufh:bar"); + }); + client.on("Room.timeline", function(event, room) { + timelineFireCount++; + expect(room.roomId).toEqual("!erufh:bar"); + }); + client.on("Room.name", function(room) { + roomNameInvokeCount++; + }); + + client.startClient(); + + return Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 2), + ]).then(function() { + expect(roomInvokeCount).toEqual( + 1, "Room fired wrong number of times.", + ); + expect(roomNameInvokeCount).toEqual( + 1, "Room.name fired wrong number of times.", + ); + expect(timelineFireCount).toEqual( + 3, "Room.timeline fired the wrong number of times", + ); + }); + }); + + it("should emit RoomState events", function() { + httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + + const roomStateEventTypes = [ + "m.room.member", "m.room.create", + ]; + let eventsInvokeCount = 0; + let membersInvokeCount = 0; + let newMemberInvokeCount = 0; + client.on("RoomState.events", function(event, state) { + eventsInvokeCount++; + const index = roomStateEventTypes.indexOf(event.getType()); + expect(index).toNotEqual( + -1, "Unexpected room state event type: " + event.getType(), + ); + if (index >= 0) { + roomStateEventTypes.splice(index, 1); + } + }); + client.on("RoomState.members", function(event, state, member) { + membersInvokeCount++; + expect(member.roomId).toEqual("!erufh:bar"); + expect(member.userId).toEqual("@foo:bar"); + expect(member.membership).toEqual("join"); + }); + client.on("RoomState.newMember", function(event, state, member) { + newMemberInvokeCount++; + expect(member.roomId).toEqual("!erufh:bar"); + expect(member.userId).toEqual("@foo:bar"); + expect(member.membership).toBeFalsy(); + }); + + client.startClient(); + + return Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 2), + ]).then(function() { + expect(membersInvokeCount).toEqual( + 1, "RoomState.members fired wrong number of times", + ); + expect(newMemberInvokeCount).toEqual( + 1, "RoomState.newMember fired wrong number of times", + ); + expect(eventsInvokeCount).toEqual( + 2, "RoomState.events fired wrong number of times", + ); + }); + }); + + it("should emit RoomMember events", function() { + httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + + let typingInvokeCount = 0; + let powerLevelInvokeCount = 0; + let nameInvokeCount = 0; + let membershipInvokeCount = 0; + client.on("RoomMember.name", function(event, member) { + nameInvokeCount++; + }); + client.on("RoomMember.typing", function(event, member) { + typingInvokeCount++; + expect(member.typing).toBe(true); + }); + client.on("RoomMember.powerLevel", function(event, member) { + powerLevelInvokeCount++; + }); + client.on("RoomMember.membership", function(event, member) { + membershipInvokeCount++; + expect(member.membership).toEqual("join"); + }); + + client.startClient(); + + return Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 2), + ]).then(function() { + expect(typingInvokeCount).toEqual( + 1, "RoomMember.typing fired wrong number of times", + ); + expect(powerLevelInvokeCount).toEqual( + 0, "RoomMember.powerLevel fired wrong number of times", + ); + expect(nameInvokeCount).toEqual( + 0, "RoomMember.name fired wrong number of times", + ); + expect(membershipInvokeCount).toEqual( + 1, "RoomMember.membership fired wrong number of times", + ); + }); + }); + + it("should emit Session.logged_out on M_UNKNOWN_TOKEN", function() { + const error = { errcode: 'M_UNKNOWN_TOKEN' }; + httpBackend.when("GET", "/sync").respond(401, error); + + let sessionLoggedOutCount = 0; + client.on("Session.logged_out", function(errObj) { + sessionLoggedOutCount++; + expect(errObj.data).toEqual(error); + }); + + client.startClient(); + + return httpBackend.flushAllExpected().then(function() { + expect(sessionLoggedOutCount).toEqual( + 1, "Session.logged_out fired wrong number of times", + ); + }); + }); + + it("should emit Session.logged_out on M_UNKNOWN_TOKEN (soft logout)", function() { + const error = { errcode: 'M_UNKNOWN_TOKEN', soft_logout: true }; + httpBackend.when("GET", "/sync").respond(401, error); + + let sessionLoggedOutCount = 0; + client.on("Session.logged_out", function(errObj) { + sessionLoggedOutCount++; + expect(errObj.data).toEqual(error); + }); + + client.startClient(); + + return httpBackend.flushAllExpected().then(function() { + expect(sessionLoggedOutCount).toEqual( + 1, "Session.logged_out fired wrong number of times", + ); + }); + }); + }); +}); diff --git a/matrix-js-sdk/spec/integ/matrix-client-event-timeline.spec.js b/matrix-js-sdk/spec/integ/matrix-client-event-timeline.spec.js new file mode 100644 index 000000000..a89818abc --- /dev/null +++ b/matrix-js-sdk/spec/integ/matrix-client-event-timeline.spec.js @@ -0,0 +1,770 @@ +"use strict"; +import 'source-map-support/register'; +import Promise from 'bluebird'; +const sdk = require("../.."); +const HttpBackend = require("matrix-mock-request"); +const utils = require("../test-utils"); +const EventTimeline = sdk.EventTimeline; +import logger from '../../src/logger'; + +const baseUrl = "http://localhost.or.something"; +const userId = "@alice:localhost"; +const userName = "Alice"; +const accessToken = "aseukfgwef"; +const roomId = "!foo:bar"; +const otherUserId = "@bob:localhost"; + +const USER_MEMBERSHIP_EVENT = utils.mkMembership({ + room: roomId, mship: "join", user: userId, name: userName, +}); + +const ROOM_NAME_EVENT = utils.mkEvent({ + type: "m.room.name", room: roomId, user: otherUserId, + content: { + name: "Old room name", + }, +}); + +const INITIAL_SYNC_DATA = { + next_batch: "s_5_3", + rooms: { + join: { + "!foo:bar": { // roomId + timeline: { + events: [ + utils.mkMessage({ + room: roomId, user: otherUserId, msg: "hello", + }), + ], + prev_batch: "f_1_1", + }, + state: { + events: [ + ROOM_NAME_EVENT, + utils.mkMembership({ + room: roomId, mship: "join", + user: otherUserId, name: "Bob", + }), + USER_MEMBERSHIP_EVENT, + utils.mkEvent({ + type: "m.room.create", room: roomId, user: userId, + content: { + creator: userId, + }, + }), + ], + }, + }, + }, + }, +}; + +const EVENTS = [ + utils.mkMessage({ + room: roomId, user: userId, msg: "we", + }), + utils.mkMessage({ + room: roomId, user: userId, msg: "could", + }), + utils.mkMessage({ + room: roomId, user: userId, msg: "be", + }), + utils.mkMessage({ + room: roomId, user: userId, msg: "heroes", + }), +]; + +// start the client, and wait for it to initialise +function startClient(httpBackend, client) { + httpBackend.when("GET", "/pushrules").respond(200, {}); + httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); + httpBackend.when("GET", "/sync").respond(200, INITIAL_SYNC_DATA); + + client.startClient(); + + // set up a promise which will resolve once the client is initialised + const deferred = Promise.defer(); + client.on("sync", function(state) { + logger.log("sync", state); + if (state != "SYNCING") { + return; + } + deferred.resolve(); + }); + + return Promise.all([ + httpBackend.flushAllExpected(), + deferred.promise, + ]); +} + +describe("getEventTimeline support", function() { + let httpBackend; + let client; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + httpBackend = new HttpBackend(); + sdk.request(httpBackend.requestFn); + }); + + afterEach(function() { + if (client) { + client.stopClient(); + } + return httpBackend.stop(); + }); + + it("timeline support must be enabled to work", function(done) { + client = sdk.createClient({ + baseUrl: baseUrl, + userId: userId, + accessToken: accessToken, + }); + + startClient(httpBackend, client, + ).then(function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + expect(function() { + client.getEventTimeline(timelineSet, "event"); + }).toThrow(); + }).nodeify(done); + }); + + it("timeline support works when enabled", function() { + client = sdk.createClient({ + baseUrl: baseUrl, + userId: userId, + accessToken: accessToken, + timelineSupport: true, + }); + + return startClient(httpBackend, client).then(() => { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + expect(function() { + client.getEventTimeline(timelineSet, "event"); + }).toNotThrow(); + }); + }); + + + it("scrollback should be able to scroll back to before a gappy /sync", + function(done) { + // need a client with timelineSupport disabled to make this work + client = sdk.createClient({ + baseUrl: baseUrl, + userId: userId, + accessToken: accessToken, + }); + let room; + + startClient(httpBackend, client, + ).then(function() { + room = client.getRoom(roomId); + + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_4", + rooms: { + join: { + "!foo:bar": { + timeline: { + events: [ + EVENTS[0], + ], + prev_batch: "f_1_1", + }, + }, + }, + }, + }); + + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_5", + rooms: { + join: { + "!foo:bar": { + timeline: { + events: [ + EVENTS[1], + ], + limited: true, + prev_batch: "f_1_2", + }, + }, + }, + }, + }); + + return Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 2), + ]); + }).then(function() { + expect(room.timeline.length).toEqual(1); + expect(room.timeline[0].event).toEqual(EVENTS[1]); + + httpBackend.when("GET", "/messages").respond(200, { + chunk: [EVENTS[0]], + start: "pagin_start", + end: "pagin_end", + }); + httpBackend.flush("/messages", 1); + return client.scrollback(room); + }).then(function() { + expect(room.timeline.length).toEqual(2); + expect(room.timeline[0].event).toEqual(EVENTS[0]); + expect(room.timeline[1].event).toEqual(EVENTS[1]); + expect(room.oldState.paginationToken).toEqual("pagin_end"); + }).nodeify(done); + }); +}); + +import expect from 'expect'; + +describe("MatrixClient event timelines", function() { + let client = null; + let httpBackend = null; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + httpBackend = new HttpBackend(); + sdk.request(httpBackend.requestFn); + + client = sdk.createClient({ + baseUrl: baseUrl, + userId: userId, + accessToken: accessToken, + timelineSupport: true, + }); + + return startClient(httpBackend, client); + }); + + afterEach(function() { + httpBackend.verifyNoOutstandingExpectation(); + client.stopClient(); + }); + + describe("getEventTimeline", function() { + it("should create a new timeline for new events", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1%3Abar") + .respond(200, function() { + return { + start: "start_token", + events_before: [EVENTS[1], EVENTS[0]], + event: EVENTS[2], + events_after: [EVENTS[3]], + state: [ + ROOM_NAME_EVENT, + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + }); + + return Promise.all([ + client.getEventTimeline(timelineSet, "event1:bar").then(function(tl) { + expect(tl.getEvents().length).toEqual(4); + for (let i = 0; i < 4; i++) { + expect(tl.getEvents()[i].event).toEqual(EVENTS[i]); + expect(tl.getEvents()[i].sender.name).toEqual(userName); + } + expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("start_token"); + expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + .toEqual("end_token"); + }), + httpBackend.flushAllExpected(), + ]); + }); + + it("should return existing timeline for known events", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_4", + rooms: { + join: { + "!foo:bar": { + timeline: { + events: [ + EVENTS[0], + ], + prev_batch: "f_1_2", + }, + }, + }, + }, + }); + + return Promise.all([ + httpBackend.flush("/sync"), + utils.syncPromise(client), + ]).then(function() { + return client.getEventTimeline(timelineSet, EVENTS[0].event_id); + }).then(function(tl) { + expect(tl.getEvents().length).toEqual(2); + expect(tl.getEvents()[1].event).toEqual(EVENTS[0]); + expect(tl.getEvents()[1].sender.name).toEqual(userName); + expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("f_1_1"); + // expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + // .toEqual("s_5_4"); + }); + }); + + it("should update timelines where they overlap a previous /sync", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_4", + rooms: { + join: { + "!foo:bar": { + timeline: { + events: [ + EVENTS[3], + ], + prev_batch: "f_1_2", + }, + }, + }, + }, + }); + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + + encodeURIComponent(EVENTS[2].event_id)) + .respond(200, function() { + return { + start: "start_token", + events_before: [EVENTS[1]], + event: EVENTS[2], + events_after: [EVENTS[3]], + end: "end_token", + state: [], + }; + }); + + const deferred = Promise.defer(); + client.on("sync", function() { + client.getEventTimeline(timelineSet, EVENTS[2].event_id, + ).then(function(tl) { + expect(tl.getEvents().length).toEqual(4); + expect(tl.getEvents()[0].event).toEqual(EVENTS[1]); + expect(tl.getEvents()[1].event).toEqual(EVENTS[2]); + expect(tl.getEvents()[3].event).toEqual(EVENTS[3]); + expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("start_token"); + // expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + // .toEqual("s_5_4"); + }).done(() => deferred.resolve(), + (e) => deferred.reject(e)); + }); + + return Promise.all([ + httpBackend.flushAllExpected(), + deferred.promise, + ]); + }); + + it("should join timelines where they overlap a previous /context", + function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + // we fetch event 0, then 2, then 3, and finally 1. 1 is returned + // with context which joins them all up. + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + + encodeURIComponent(EVENTS[0].event_id)) + .respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: EVENTS[0], + events_after: [], + end: "end_token0", + state: [], + }; + }); + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + + encodeURIComponent(EVENTS[2].event_id)) + .respond(200, function() { + return { + start: "start_token2", + events_before: [], + event: EVENTS[2], + events_after: [], + end: "end_token2", + state: [], + }; + }); + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + + encodeURIComponent(EVENTS[3].event_id)) + .respond(200, function() { + return { + start: "start_token3", + events_before: [], + event: EVENTS[3], + events_after: [], + end: "end_token3", + state: [], + }; + }); + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + + encodeURIComponent(EVENTS[1].event_id)) + .respond(200, function() { + return { + start: "start_token4", + events_before: [EVENTS[0]], + event: EVENTS[1], + events_after: [EVENTS[2], EVENTS[3]], + end: "end_token4", + state: [], + }; + }); + + let tl0; + let tl3; + return Promise.all([ + client.getEventTimeline(timelineSet, EVENTS[0].event_id, + ).then(function(tl) { + expect(tl.getEvents().length).toEqual(1); + tl0 = tl; + return client.getEventTimeline(timelineSet, EVENTS[2].event_id); + }).then(function(tl) { + expect(tl.getEvents().length).toEqual(1); + return client.getEventTimeline(timelineSet, EVENTS[3].event_id); + }).then(function(tl) { + expect(tl.getEvents().length).toEqual(1); + tl3 = tl; + return client.getEventTimeline(timelineSet, EVENTS[1].event_id); + }).then(function(tl) { + // we expect it to get merged in with event 2 + expect(tl.getEvents().length).toEqual(2); + expect(tl.getEvents()[0].event).toEqual(EVENTS[1]); + expect(tl.getEvents()[1].event).toEqual(EVENTS[2]); + expect(tl.getNeighbouringTimeline(EventTimeline.BACKWARDS)) + .toBe(tl0); + expect(tl.getNeighbouringTimeline(EventTimeline.FORWARDS)) + .toBe(tl3); + expect(tl0.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("start_token0"); + expect(tl0.getPaginationToken(EventTimeline.FORWARDS)) + .toBe(null); + expect(tl3.getPaginationToken(EventTimeline.BACKWARDS)) + .toBe(null); + expect(tl3.getPaginationToken(EventTimeline.FORWARDS)) + .toEqual("end_token3"); + }), + httpBackend.flushAllExpected(), + ]); + }); + + it("should fail gracefully if there is no event field", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + // we fetch event 0, then 2, then 3, and finally 1. 1 is returned + // with context which joins them all up. + httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1") + .respond(200, function() { + return { + start: "start_token", + events_before: [], + events_after: [], + end: "end_token", + state: [], + }; + }); + + return Promise.all([ + client.getEventTimeline(timelineSet, "event1", + ).then(function(tl) { + // could do with a fail() + expect(true).toBeFalsy(); + }, function(e) { + expect(String(e)).toMatch(/'event'/); + }), + httpBackend.flushAllExpected(), + ]); + }); + }); + + describe("paginateEventTimeline", function() { + it("should allow you to paginate backwards", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + + encodeURIComponent(EVENTS[0].event_id)) + .respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: EVENTS[0], + events_after: [], + end: "end_token0", + state: [], + }; + }); + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .check(function(req) { + const params = req.queryParams; + expect(params.dir).toEqual("b"); + expect(params.from).toEqual("start_token0"); + expect(params.limit).toEqual(30); + }).respond(200, function() { + return { + chunk: [EVENTS[1], EVENTS[2]], + end: "start_token1", + }; + }); + + let tl; + return Promise.all([ + client.getEventTimeline(timelineSet, EVENTS[0].event_id, + ).then(function(tl0) { + tl = tl0; + return client.paginateEventTimeline(tl, {backwards: true}); + }).then(function(success) { + expect(success).toBeTruthy(); + expect(tl.getEvents().length).toEqual(3); + expect(tl.getEvents()[0].event).toEqual(EVENTS[2]); + expect(tl.getEvents()[1].event).toEqual(EVENTS[1]); + expect(tl.getEvents()[2].event).toEqual(EVENTS[0]); + expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("start_token1"); + expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + .toEqual("end_token0"); + }), + httpBackend.flushAllExpected(), + ]); + }); + + + it("should allow you to paginate forwards", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + + encodeURIComponent(EVENTS[0].event_id)) + .respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: EVENTS[0], + events_after: [], + end: "end_token0", + state: [], + }; + }); + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .check(function(req) { + const params = req.queryParams; + expect(params.dir).toEqual("f"); + expect(params.from).toEqual("end_token0"); + expect(params.limit).toEqual(20); + }).respond(200, function() { + return { + chunk: [EVENTS[1], EVENTS[2]], + end: "end_token1", + }; + }); + + let tl; + return Promise.all([ + client.getEventTimeline(timelineSet, EVENTS[0].event_id, + ).then(function(tl0) { + tl = tl0; + return client.paginateEventTimeline( + tl, {backwards: false, limit: 20}); + }).then(function(success) { + expect(success).toBeTruthy(); + expect(tl.getEvents().length).toEqual(3); + expect(tl.getEvents()[0].event).toEqual(EVENTS[0]); + expect(tl.getEvents()[1].event).toEqual(EVENTS[1]); + expect(tl.getEvents()[2].event).toEqual(EVENTS[2]); + expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("start_token0"); + expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + .toEqual("end_token1"); + }), + httpBackend.flushAllExpected(), + ]); + }); + }); + + describe("event timeline for sent events", function() { + const TXN_ID = "txn1"; + const event = utils.mkMessage({ + room: roomId, user: userId, msg: "a body", + }); + event.unsigned = {transaction_id: TXN_ID}; + + beforeEach(function() { + // set up handlers for both the message send, and the + // /sync + httpBackend.when("PUT", "/send/m.room.message/" + TXN_ID) + .respond(200, { + event_id: event.event_id, + }); + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_4", + rooms: { + join: { + "!foo:bar": { + timeline: { + events: [ + event, + ], + prev_batch: "f_1_1", + }, + }, + }, + }, + }); + }); + + it("should work when /send returns before /sync", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + return Promise.all([ + client.sendTextMessage(roomId, "a body", TXN_ID).then(function(res) { + expect(res.event_id).toEqual(event.event_id); + return client.getEventTimeline(timelineSet, event.event_id); + }).then(function(tl) { + // 2 because the initial sync contained an event + expect(tl.getEvents().length).toEqual(2); + expect(tl.getEvents()[1].getContent().body).toEqual("a body"); + + // now let the sync complete, and check it again + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]); + }).then(function() { + return client.getEventTimeline(timelineSet, event.event_id); + }).then(function(tl) { + expect(tl.getEvents().length).toEqual(2); + expect(tl.getEvents()[1].event).toEqual(event); + }), + + httpBackend.flush("/send/m.room.message/" + TXN_ID, 1), + ]); + }); + + it("should work when /send returns after /sync", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + return Promise.all([ + // initiate the send, and set up checks to be done when it completes + // - but note that it won't complete until after the /sync does, below. + client.sendTextMessage(roomId, "a body", TXN_ID).then(function(res) { + logger.log("sendTextMessage completed"); + expect(res.event_id).toEqual(event.event_id); + return client.getEventTimeline(timelineSet, event.event_id); + }).then(function(tl) { + logger.log("getEventTimeline completed (2)"); + expect(tl.getEvents().length).toEqual(2); + expect(tl.getEvents()[1].getContent().body).toEqual("a body"); + }), + + Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(function() { + return client.getEventTimeline(timelineSet, event.event_id); + }).then(function(tl) { + logger.log("getEventTimeline completed (1)"); + expect(tl.getEvents().length).toEqual(2); + expect(tl.getEvents()[1].event).toEqual(event); + + // now let the send complete. + return httpBackend.flush("/send/m.room.message/" + TXN_ID, 1); + }), + ]); + }); + }); + + + it("should handle gappy syncs after redactions", function(done) { + // https://github.com/vector-im/vector-web/issues/1389 + + // a state event, followed by a redaction thereof + const event = utils.mkMembership({ + room: roomId, mship: "join", user: otherUserId, + }); + const redaction = utils.mkEvent({ + type: "m.room.redaction", + room_id: roomId, + sender: otherUserId, + content: {}, + }); + redaction.redacts = event.event_id; + + const syncData = { + next_batch: "batch1", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomId] = { + timeline: { + events: [ + event, + redaction, + ], + limited: false, + }, + }; + httpBackend.when("GET", "/sync").respond(200, syncData); + + Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client), + ]).then(function() { + const room = client.getRoom(roomId); + const tl = room.getLiveTimeline(); + expect(tl.getEvents().length).toEqual(3); + expect(tl.getEvents()[1].isRedacted()).toBe(true); + + const sync2 = { + next_batch: "batch2", + rooms: { + join: {}, + }, + }; + sync2.rooms.join[roomId] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomId, user: otherUserId, msg: "world", + }), + ], + limited: true, + prev_batch: "newerTok", + }, + }; + httpBackend.when("GET", "/sync").respond(200, sync2); + + return Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client), + ]); + }).then(function() { + const room = client.getRoom(roomId); + const tl = room.getLiveTimeline(); + expect(tl.getEvents().length).toEqual(1); + }).nodeify(done); + }); +}); diff --git a/matrix-js-sdk/spec/integ/matrix-client-methods.spec.js b/matrix-js-sdk/spec/integ/matrix-client-methods.spec.js new file mode 100644 index 000000000..3e4cf95b2 --- /dev/null +++ b/matrix-js-sdk/spec/integ/matrix-client-methods.spec.js @@ -0,0 +1,419 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const HttpBackend = require("matrix-mock-request"); +const publicGlobals = require("../../lib/matrix"); +const Room = publicGlobals.Room; +const MemoryStore = publicGlobals.MemoryStore; +const Filter = publicGlobals.Filter; +const utils = require("../test-utils"); +const MockStorageApi = require("../MockStorageApi"); + +import expect from 'expect'; + +describe("MatrixClient", function() { + const baseUrl = "http://localhost.or.something"; + let client = null; + let httpBackend = null; + let store = null; + let sessionStore = null; + const userId = "@alice:localhost"; + const accessToken = "aseukfgwef"; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + httpBackend = new HttpBackend(); + store = new MemoryStore(); + + const mockStorage = new MockStorageApi(); + sessionStore = new sdk.WebStorageSessionStore(mockStorage); + + sdk.request(httpBackend.requestFn); + client = sdk.createClient({ + baseUrl: baseUrl, + userId: userId, + deviceId: "aliceDevice", + accessToken: accessToken, + store: store, + sessionStore: sessionStore, + }); + }); + + afterEach(function() { + httpBackend.verifyNoOutstandingExpectation(); + return httpBackend.stop(); + }); + + describe("uploadContent", function() { + const buf = new Buffer('hello world'); + it("should upload the file", function(done) { + httpBackend.when( + "POST", "/_matrix/media/r0/upload", + ).check(function(req) { + expect(req.rawData).toEqual(buf); + expect(req.queryParams.filename).toEqual("hi.txt"); + if (!(req.queryParams.access_token == accessToken || + req.headers["Authorization"] == "Bearer " + accessToken)) { + expect(true).toBe(false); + } + expect(req.headers["Content-Type"]).toEqual("text/plain"); + expect(req.opts.json).toBeFalsy(); + expect(req.opts.timeout).toBe(undefined); + }).respond(200, "content", true); + + const prom = client.uploadContent({ + stream: buf, + name: "hi.txt", + type: "text/plain", + }); + + expect(prom).toBeTruthy(); + + const uploads = client.getCurrentUploads(); + expect(uploads.length).toEqual(1); + expect(uploads[0].promise).toBe(prom); + expect(uploads[0].loaded).toEqual(0); + + prom.then(function(response) { + // for backwards compatibility, we return the raw JSON + expect(response).toEqual("content"); + + const uploads = client.getCurrentUploads(); + expect(uploads.length).toEqual(0); + }).nodeify(done); + + httpBackend.flush(); + }); + + it("should parse the response if rawResponse=false", function(done) { + httpBackend.when( + "POST", "/_matrix/media/r0/upload", + ).check(function(req) { + expect(req.opts.json).toBeFalsy(); + }).respond(200, { "content_uri": "uri" }); + + client.uploadContent({ + stream: buf, + name: "hi.txt", + type: "text/plain", + }, { + rawResponse: false, + }).then(function(response) { + expect(response.content_uri).toEqual("uri"); + }).nodeify(done); + + httpBackend.flush(); + }); + + it("should parse errors into a MatrixError", function(done) { + httpBackend.when( + "POST", "/_matrix/media/r0/upload", + ).check(function(req) { + expect(req.rawData).toEqual(buf); + expect(req.opts.json).toBeFalsy(); + }).respond(400, { + "errcode": "M_SNAFU", + "error": "broken", + }); + + client.uploadContent({ + stream: buf, + name: "hi.txt", + type: "text/plain", + }).then(function(response) { + throw Error("request not failed"); + }, function(error) { + expect(error.httpStatus).toEqual(400); + expect(error.errcode).toEqual("M_SNAFU"); + expect(error.message).toEqual("broken"); + }).nodeify(done); + + httpBackend.flush(); + }); + + it("should return a promise which can be cancelled", function(done) { + const prom = client.uploadContent({ + stream: buf, + name: "hi.txt", + type: "text/plain", + }); + + const uploads = client.getCurrentUploads(); + expect(uploads.length).toEqual(1); + expect(uploads[0].promise).toBe(prom); + expect(uploads[0].loaded).toEqual(0); + + prom.then(function(response) { + throw Error("request not aborted"); + }, function(error) { + expect(error).toEqual("aborted"); + + const uploads = client.getCurrentUploads(); + expect(uploads.length).toEqual(0); + }).nodeify(done); + + const r = client.cancelUpload(prom); + expect(r).toBe(true); + }); + }); + + describe("joinRoom", function() { + it("should no-op if you've already joined a room", function() { + const roomId = "!foo:bar"; + const room = new Room(roomId, userId); + room.addLiveEvents([ + utils.mkMembership({ + user: userId, room: roomId, mship: "join", event: true, + }), + ]); + store.storeRoom(room); + client.joinRoom(roomId); + httpBackend.verifyNoOutstandingRequests(); + }); + }); + + describe("getFilter", function() { + const filterId = "f1lt3r1d"; + + it("should return a filter from the store if allowCached", function(done) { + const filter = Filter.fromJson(userId, filterId, { + event_format: "client", + }); + store.storeFilter(filter); + client.getFilter(userId, filterId, true).done(function(gotFilter) { + expect(gotFilter).toEqual(filter); + done(); + }); + httpBackend.verifyNoOutstandingRequests(); + }); + + it("should do an HTTP request if !allowCached even if one exists", + function(done) { + const httpFilterDefinition = { + event_format: "federation", + }; + + httpBackend.when( + "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, + ).respond(200, httpFilterDefinition); + + const storeFilter = Filter.fromJson(userId, filterId, { + event_format: "client", + }); + store.storeFilter(storeFilter); + client.getFilter(userId, filterId, false).done(function(gotFilter) { + expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition); + done(); + }); + + httpBackend.flush(); + }); + + it("should do an HTTP request if nothing is in the cache and then store it", + function(done) { + const httpFilterDefinition = { + event_format: "federation", + }; + expect(store.getFilter(userId, filterId)).toBe(null); + + httpBackend.when( + "GET", "/user/" + encodeURIComponent(userId) + "/filter/" + filterId, + ).respond(200, httpFilterDefinition); + client.getFilter(userId, filterId, true).done(function(gotFilter) { + expect(gotFilter.getDefinition()).toEqual(httpFilterDefinition); + expect(store.getFilter(userId, filterId)).toBeTruthy(); + done(); + }); + + httpBackend.flush(); + }); + }); + + describe("createFilter", function() { + const filterId = "f1llllllerid"; + + it("should do an HTTP request and then store the filter", function(done) { + expect(store.getFilter(userId, filterId)).toBe(null); + + const filterDefinition = { + event_format: "client", + }; + + httpBackend.when( + "POST", "/user/" + encodeURIComponent(userId) + "/filter", + ).check(function(req) { + expect(req.data).toEqual(filterDefinition); + }).respond(200, { + filter_id: filterId, + }); + + client.createFilter(filterDefinition).done(function(gotFilter) { + expect(gotFilter.getDefinition()).toEqual(filterDefinition); + expect(store.getFilter(userId, filterId)).toEqual(gotFilter); + done(); + }); + + httpBackend.flush(); + }); + }); + + describe("searching", function() { + const response = { + search_categories: { + room_events: { + count: 24, + results: { + "$flibble:localhost": { + rank: 0.1, + result: { + type: "m.room.message", + user_id: "@alice:localhost", + room_id: "!feuiwhf:localhost", + content: { + body: "a result", + msgtype: "m.text", + }, + }, + }, + }, + }, + }, + }; + + it("searchMessageText should perform a /search for room_events", function(done) { + client.searchMessageText({ + query: "monkeys", + }); + httpBackend.when("POST", "/search").check(function(req) { + expect(req.data).toEqual({ + search_categories: { + room_events: { + search_term: "monkeys", + }, + }, + }); + }).respond(200, response); + + httpBackend.flush().done(function() { + done(); + }); + }); + }); + + + describe("downloadKeys", function() { + if (!sdk.CRYPTO_ENABLED) { + return; + } + + beforeEach(function() { + return client.initCrypto(); + }); + + it("should do an HTTP request and then store the keys", function(done) { + const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78"; + // ed25519key = client.getDeviceEd25519Key(); + const borisKeys = { + dev1: { + algorithms: ["1"], + device_id: "dev1", + keys: { "ed25519:dev1": ed25519key }, + signatures: { + boris: { + "ed25519:dev1": + "RAhmbNDq1efK3hCpBzZDsKoGSsrHUxb25NW5/WbEV9R" + + "JVwLdP032mg5QsKt/pBDUGtggBcnk43n3nBWlA88WAw", + }, + }, + unsigned: { "abc": "def" }, + user_id: "boris", + }, + }; + const chazKeys = { + dev2: { + algorithms: ["2"], + device_id: "dev2", + keys: { "ed25519:dev2": ed25519key }, + signatures: { + chaz: { + "ed25519:dev2": + "FwslH/Q7EYSb7swDJbNB5PSzcbEO1xRRBF1riuijqvL" + + "EkrK9/XVN8jl4h7thGuRITQ01siBQnNmMK9t45QfcCQ", + }, + }, + unsigned: { "ghi": "def" }, + user_id: "chaz", + }, + }; + + /* + function sign(o) { + var anotherjson = require('another-json'); + var b = JSON.parse(JSON.stringify(o)); + delete(b.signatures); + delete(b.unsigned); + return client._crypto._olmDevice.sign(anotherjson.stringify(b)); + }; + + logger.log("Ed25519: " + ed25519key); + logger.log("boris:", sign(borisKeys.dev1)); + logger.log("chaz:", sign(chazKeys.dev2)); + */ + + httpBackend.when("POST", "/keys/query").check(function(req) { + expect(req.data).toEqual({device_keys: { + 'boris': [], + 'chaz': [], + }}); + }).respond(200, { + device_keys: { + boris: borisKeys, + chaz: chazKeys, + }, + }); + + client.downloadKeys(["boris", "chaz"]).then(function(res) { + assertObjectContains(res.boris.dev1, { + verified: 0, // DeviceVerification.UNVERIFIED + keys: { "ed25519:dev1": ed25519key }, + algorithms: ["1"], + unsigned: { "abc": "def" }, + }); + + assertObjectContains(res.chaz.dev2, { + verified: 0, // DeviceVerification.UNVERIFIED + keys: { "ed25519:dev2": ed25519key }, + algorithms: ["2"], + unsigned: { "ghi": "def" }, + }); + }).nodeify(done); + + httpBackend.flush(); + }); + }); + + describe("deleteDevice", function() { + const auth = {a: 1}; + it("should pass through an auth dict", function(done) { + httpBackend.when( + "DELETE", "/_matrix/client/r0/devices/my_device", + ).check(function(req) { + expect(req.data).toEqual({auth: auth}); + }).respond(200); + + client.deleteDevice( + "my_device", auth, + ).nodeify(done); + + httpBackend.flush(); + }); + }); +}); + +function assertObjectContains(obj, expected) { + for (const k in expected) { + if (expected.hasOwnProperty(k)) { + expect(obj[k]).toEqual(expected[k]); + } + } +} diff --git a/matrix-js-sdk/spec/integ/matrix-client-opts.spec.js b/matrix-js-sdk/spec/integ/matrix-client-opts.spec.js new file mode 100644 index 000000000..11bcf6b6d --- /dev/null +++ b/matrix-js-sdk/spec/integ/matrix-client-opts.spec.js @@ -0,0 +1,188 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const MatrixClient = sdk.MatrixClient; +const HttpBackend = require("matrix-mock-request"); +const utils = require("../test-utils"); + +import expect from 'expect'; +import Promise from 'bluebird'; + +describe("MatrixClient opts", function() { + const baseUrl = "http://localhost.or.something"; + let client = null; + let httpBackend = null; + const userId = "@alice:localhost"; + const userB = "@bob:localhost"; + const accessToken = "aseukfgwef"; + const roomId = "!foo:bar"; + const syncData = { + next_batch: "s_5_3", + presence: {}, + rooms: { + join: { + "!foo:bar": { // roomId + timeline: { + events: [ + utils.mkMessage({ + room: roomId, user: userB, msg: "hello", + }), + ], + prev_batch: "f_1_1", + }, + state: { + events: [ + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userB, + content: { + name: "Old room name", + }, + }), + utils.mkMembership({ + room: roomId, mship: "join", user: userB, name: "Bob", + }), + utils.mkMembership({ + room: roomId, mship: "join", user: userId, name: "Alice", + }), + utils.mkEvent({ + type: "m.room.create", room: roomId, user: userId, + content: { + creator: userId, + }, + }), + ], + }, + }, + }, + }, + }; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + httpBackend = new HttpBackend(); + }); + + afterEach(function() { + httpBackend.verifyNoOutstandingExpectation(); + return httpBackend.stop(); + }); + + describe("without opts.store", function() { + beforeEach(function() { + client = new MatrixClient({ + request: httpBackend.requestFn, + store: undefined, + baseUrl: baseUrl, + userId: userId, + accessToken: accessToken, + scheduler: new sdk.MatrixScheduler(), + }); + }); + + afterEach(function() { + client.stopClient(); + }); + + it("should be able to send messages", function(done) { + const eventId = "$flibble:wibble"; + httpBackend.when("PUT", "/txn1").respond(200, { + event_id: eventId, + }); + client.sendTextMessage("!foo:bar", "a body", "txn1").done(function(res) { + expect(res.event_id).toEqual(eventId); + done(); + }); + httpBackend.flush("/txn1", 1); + }); + + it("should be able to sync / get new events", async function() { + const expectedEventTypes = [ // from /initialSync + "m.room.message", "m.room.name", "m.room.member", "m.room.member", + "m.room.create", + ]; + client.on("event", function(event) { + expect(expectedEventTypes.indexOf(event.getType())).toNotEqual( + -1, "Recv unexpected event type: " + event.getType(), + ); + expectedEventTypes.splice( + expectedEventTypes.indexOf(event.getType()), 1, + ); + }); + httpBackend.when("GET", "/pushrules").respond(200, {}); + httpBackend.when("POST", "/filter").respond(200, { filter_id: "foo" }); + httpBackend.when("GET", "/sync").respond(200, syncData); + await client.startClient(); + await httpBackend.flush("/pushrules", 1); + await httpBackend.flush("/filter", 1); + await Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]); + expect(expectedEventTypes.length).toEqual( + 0, "Expected to see event types: " + expectedEventTypes, + ); + }); + }); + + describe("without opts.scheduler", function() { + beforeEach(function() { + client = new MatrixClient({ + request: httpBackend.requestFn, + store: new sdk.MemoryStore(), + baseUrl: baseUrl, + userId: userId, + accessToken: accessToken, + scheduler: undefined, + }); + }); + + it("shouldn't retry sending events", function(done) { + httpBackend.when("PUT", "/txn1").fail(500, { + errcode: "M_SOMETHING", + error: "Ruh roh", + }); + client.sendTextMessage("!foo:bar", "a body", "txn1").done(function(res) { + expect(false).toBe(true, "sendTextMessage resolved but shouldn't"); + }, function(err) { + expect(err.errcode).toEqual("M_SOMETHING"); + done(); + }); + httpBackend.flush("/txn1", 1); + }); + + it("shouldn't queue events", function(done) { + httpBackend.when("PUT", "/txn1").respond(200, { + event_id: "AAA", + }); + httpBackend.when("PUT", "/txn2").respond(200, { + event_id: "BBB", + }); + let sentA = false; + let sentB = false; + client.sendTextMessage("!foo:bar", "a body", "txn1").done(function(res) { + sentA = true; + expect(sentB).toBe(true); + }); + client.sendTextMessage("!foo:bar", "b body", "txn2").done(function(res) { + sentB = true; + expect(sentA).toBe(false); + }); + httpBackend.flush("/txn2", 1).done(function() { + httpBackend.flush("/txn1", 1).done(function() { + done(); + }); + }); + }); + + it("should be able to send messages", function(done) { + httpBackend.when("PUT", "/txn1").respond(200, { + event_id: "foo", + }); + client.sendTextMessage("!foo:bar", "a body", "txn1").done(function(res) { + expect(res.event_id).toEqual("foo"); + done(); + }); + httpBackend.flush("/txn1", 1); + }); + }); +}); diff --git a/matrix-js-sdk/spec/integ/matrix-client-retrying.spec.js b/matrix-js-sdk/spec/integ/matrix-client-retrying.spec.js new file mode 100644 index 000000000..0dabc0abe --- /dev/null +++ b/matrix-js-sdk/spec/integ/matrix-client-retrying.spec.js @@ -0,0 +1,129 @@ +"use strict"; +import 'source-map-support/register'; +import Promise from 'bluebird'; + +const sdk = require("../.."); +const HttpBackend = require("matrix-mock-request"); +const utils = require("../test-utils"); +const EventStatus = sdk.EventStatus; + +import expect from 'expect'; + +describe("MatrixClient retrying", function() { + const baseUrl = "http://localhost.or.something"; + let client = null; + let httpBackend = null; + let scheduler; + const userId = "@alice:localhost"; + const accessToken = "aseukfgwef"; + const roomId = "!room:here"; + let room; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + httpBackend = new HttpBackend(); + sdk.request(httpBackend.requestFn); + scheduler = new sdk.MatrixScheduler(); + client = sdk.createClient({ + baseUrl: baseUrl, + userId: userId, + accessToken: accessToken, + scheduler: scheduler, + }); + room = new sdk.Room(roomId); + client.store.storeRoom(room); + }); + + afterEach(function() { + httpBackend.verifyNoOutstandingExpectation(); + return httpBackend.stop(); + }); + + xit("should retry according to MatrixScheduler.retryFn", function() { + + }); + + xit("should queue according to MatrixScheduler.queueFn", function() { + + }); + + xit("should mark events as EventStatus.NOT_SENT when giving up", function() { + + }); + + xit("should mark events as EventStatus.QUEUED when queued", function() { + + }); + + it("should mark events as EventStatus.CANCELLED when cancelled", function() { + // send a couple of events; the second will be queued + const p1 = client.sendMessage(roomId, "m1").then(function(ev) { + // we expect the first message to fail + throw new Error('Message 1 unexpectedly sent successfully'); + }, (e) => { + // this is expected + }); + + // XXX: it turns out that the promise returned by this message + // never gets resolved. + // https://github.com/matrix-org/matrix-js-sdk/issues/496 + client.sendMessage(roomId, "m2"); + + // both events should be in the timeline at this point + const tl = room.getLiveTimeline().getEvents(); + expect(tl.length).toEqual(2); + const ev1 = tl[0]; + const ev2 = tl[1]; + + expect(ev1.status).toEqual(EventStatus.SENDING); + expect(ev2.status).toEqual(EventStatus.SENDING); + + // the first message should get sent, and the second should get queued + httpBackend.when("PUT", "/send/m.room.message/").check(function(rq) { + // ev2 should now have been queued + expect(ev2.status).toEqual(EventStatus.QUEUED); + + // now we can cancel the second and check everything looks sane + client.cancelPendingEvent(ev2); + expect(ev2.status).toEqual(EventStatus.CANCELLED); + expect(tl.length).toEqual(1); + + // shouldn't be able to cancel the first message yet + expect(function() { + client.cancelPendingEvent(ev1); + }).toThrow(); + }).respond(400); // fail the first message + + // wait for the localecho of ev1 to be updated + const p3 = new Promise((resolve, reject) => { + room.on("Room.localEchoUpdated", (ev0) => { + if(ev0 === ev1) { + resolve(); + } + }); + }).then(function() { + expect(ev1.status).toEqual(EventStatus.NOT_SENT); + expect(tl.length).toEqual(1); + + // cancel the first message + client.cancelPendingEvent(ev1); + expect(ev1.status).toEqual(EventStatus.CANCELLED); + expect(tl.length).toEqual(0); + }); + + return Promise.all([ + p1, + p3, + httpBackend.flushAllExpected(), + ]); + }); + + describe("resending", function() { + xit("should be able to resend a NOT_SENT event", function() { + + }); + xit("should be able to resend a sent event", function() { + + }); + }); +}); diff --git a/matrix-js-sdk/spec/integ/matrix-client-room-timeline.spec.js b/matrix-js-sdk/spec/integ/matrix-client-room-timeline.spec.js new file mode 100644 index 000000000..f951c0cf4 --- /dev/null +++ b/matrix-js-sdk/spec/integ/matrix-client-room-timeline.spec.js @@ -0,0 +1,614 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const EventStatus = sdk.EventStatus; +const HttpBackend = require("matrix-mock-request"); +const utils = require("../test-utils"); + +import Promise from 'bluebird'; +import expect from 'expect'; + +describe("MatrixClient room timelines", function() { + const baseUrl = "http://localhost.or.something"; + let client = null; + let httpBackend = null; + const userId = "@alice:localhost"; + const userName = "Alice"; + const accessToken = "aseukfgwef"; + const roomId = "!foo:bar"; + const otherUserId = "@bob:localhost"; + const USER_MEMBERSHIP_EVENT = utils.mkMembership({ + room: roomId, mship: "join", user: userId, name: userName, + }); + const ROOM_NAME_EVENT = utils.mkEvent({ + type: "m.room.name", room: roomId, user: otherUserId, + content: { + name: "Old room name", + }, + }); + let NEXT_SYNC_DATA; + const SYNC_DATA = { + next_batch: "s_5_3", + rooms: { + join: { + "!foo:bar": { // roomId + timeline: { + events: [ + utils.mkMessage({ + room: roomId, user: otherUserId, msg: "hello", + }), + ], + prev_batch: "f_1_1", + }, + state: { + events: [ + ROOM_NAME_EVENT, + utils.mkMembership({ + room: roomId, mship: "join", + user: otherUserId, name: "Bob", + }), + USER_MEMBERSHIP_EVENT, + utils.mkEvent({ + type: "m.room.create", room: roomId, user: userId, + content: { + creator: userId, + }, + }), + ], + }, + }, + }, + }, + }; + + function setNextSyncData(events) { + events = events || []; + NEXT_SYNC_DATA = { + next_batch: "n", + presence: { events: [] }, + rooms: { + invite: {}, + join: { + "!foo:bar": { + timeline: { events: [] }, + state: { events: [] }, + ephemeral: { events: [] }, + }, + }, + leave: {}, + }, + }; + events.forEach(function(e) { + if (e.room_id !== roomId) { + throw new Error("setNextSyncData only works with one room id"); + } + if (e.state_key) { + if (e.__prev_event === undefined) { + throw new Error( + "setNextSyncData needs the prev state set to '__prev_event' " + + "for " + e.type, + ); + } + if (e.__prev_event !== null) { + // push the previous state for this event type + NEXT_SYNC_DATA.rooms.join[roomId].state.events.push(e.__prev_event); + } + // push the current + NEXT_SYNC_DATA.rooms.join[roomId].timeline.events.push(e); + } else if (["m.typing", "m.receipt"].indexOf(e.type) !== -1) { + NEXT_SYNC_DATA.rooms.join[roomId].ephemeral.events.push(e); + } else { + NEXT_SYNC_DATA.rooms.join[roomId].timeline.events.push(e); + } + }); + } + + beforeEach(function(done) { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + httpBackend = new HttpBackend(); + sdk.request(httpBackend.requestFn); + client = sdk.createClient({ + baseUrl: baseUrl, + userId: userId, + accessToken: accessToken, + // these tests should work with or without timelineSupport + timelineSupport: true, + }); + setNextSyncData(); + httpBackend.when("GET", "/pushrules").respond(200, {}); + httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); + httpBackend.when("GET", "/sync").respond(200, SYNC_DATA); + httpBackend.when("GET", "/sync").respond(200, function() { + return NEXT_SYNC_DATA; + }); + client.startClient(); + httpBackend.flush("/pushrules").then(function() { + return httpBackend.flush("/filter"); + }).nodeify(done); + }); + + afterEach(function() { + httpBackend.verifyNoOutstandingExpectation(); + client.stopClient(); + return httpBackend.stop(); + }); + + describe("local echo events", function() { + it("should be added immediately after calling MatrixClient.sendEvent " + + "with EventStatus.SENDING and the right event.sender", function(done) { + client.on("sync", function(state) { + if (state !== "PREPARED") { + return; + } + const room = client.getRoom(roomId); + expect(room.timeline.length).toEqual(1); + + client.sendTextMessage(roomId, "I am a fish", "txn1"); + // check it was added + expect(room.timeline.length).toEqual(2); + // check status + expect(room.timeline[1].status).toEqual(EventStatus.SENDING); + // check member + const member = room.timeline[1].sender; + expect(member.userId).toEqual(userId); + expect(member.name).toEqual(userName); + + httpBackend.flush("/sync", 1).done(function() { + done(); + }); + }); + httpBackend.flush("/sync", 1); + }); + + it("should be updated correctly when the send request finishes " + + "BEFORE the event comes down the event stream", function(done) { + const eventId = "$foo:bar"; + httpBackend.when("PUT", "/txn1").respond(200, { + event_id: eventId, + }); + + const ev = utils.mkMessage({ + body: "I am a fish", user: userId, room: roomId, + }); + ev.event_id = eventId; + ev.unsigned = {transaction_id: "txn1"}; + setNextSyncData([ev]); + + client.on("sync", function(state) { + if (state !== "PREPARED") { + return; + } + const room = client.getRoom(roomId); + client.sendTextMessage(roomId, "I am a fish", "txn1").done( + function() { + expect(room.timeline[1].getId()).toEqual(eventId); + httpBackend.flush("/sync", 1).done(function() { + expect(room.timeline[1].getId()).toEqual(eventId); + done(); + }); + }); + httpBackend.flush("/txn1", 1); + }); + httpBackend.flush("/sync", 1); + }); + + it("should be updated correctly when the send request finishes " + + "AFTER the event comes down the event stream", function(done) { + const eventId = "$foo:bar"; + httpBackend.when("PUT", "/txn1").respond(200, { + event_id: eventId, + }); + + const ev = utils.mkMessage({ + body: "I am a fish", user: userId, room: roomId, + }); + ev.event_id = eventId; + ev.unsigned = {transaction_id: "txn1"}; + setNextSyncData([ev]); + + client.on("sync", function(state) { + if (state !== "PREPARED") { + return; + } + const room = client.getRoom(roomId); + const promise = client.sendTextMessage(roomId, "I am a fish", "txn1"); + httpBackend.flush("/sync", 1).done(function() { + expect(room.timeline.length).toEqual(2); + httpBackend.flush("/txn1", 1); + promise.done(function() { + expect(room.timeline.length).toEqual(2); + expect(room.timeline[1].getId()).toEqual(eventId); + done(); + }); + }); + }); + httpBackend.flush("/sync", 1); + }); + }); + + describe("paginated events", function() { + let sbEvents; + const sbEndTok = "pagin_end"; + + beforeEach(function() { + sbEvents = []; + httpBackend.when("GET", "/messages").respond(200, function() { + return { + chunk: sbEvents, + start: "pagin_start", + end: sbEndTok, + }; + }); + }); + + it("should set Room.oldState.paginationToken to null at the start" + + " of the timeline.", function(done) { + client.on("sync", function(state) { + if (state !== "PREPARED") { + return; + } + const room = client.getRoom(roomId); + expect(room.timeline.length).toEqual(1); + + client.scrollback(room).done(function() { + expect(room.timeline.length).toEqual(1); + expect(room.oldState.paginationToken).toBe(null); + + // still have a sync to flush + httpBackend.flush("/sync", 1).then(() => { + done(); + }); + }); + + httpBackend.flush("/messages", 1); + }); + httpBackend.flush("/sync", 1); + }); + + it("should set the right event.sender values", function(done) { + // We're aiming for an eventual timeline of: + // + // 'Old Alice' joined the room + // I'm old alice + // @alice:localhost changed their name from 'Old Alice' to 'Alice' + // I'm alice + // ------^ /messages results above this point, /sync result below + // hello + + // make an m.room.member event for alice's join + const joinMshipEvent = utils.mkMembership({ + mship: "join", user: userId, room: roomId, name: "Old Alice", + url: null, + }); + + // make an m.room.member event with prev_content for alice's nick + // change + const oldMshipEvent = utils.mkMembership({ + mship: "join", user: userId, room: roomId, name: userName, + url: "mxc://some/url", + }); + oldMshipEvent.prev_content = { + displayname: "Old Alice", + avatar_url: null, + membership: "join", + }; + + // set the list of events to return on scrollback (/messages) + // N.B. synapse returns /messages in reverse chronological order + sbEvents = [ + utils.mkMessage({ + user: userId, room: roomId, msg: "I'm alice", + }), + oldMshipEvent, + utils.mkMessage({ + user: userId, room: roomId, msg: "I'm old alice", + }), + joinMshipEvent, + ]; + + client.on("sync", function(state) { + if (state !== "PREPARED") { + return; + } + const room = client.getRoom(roomId); + // sync response + expect(room.timeline.length).toEqual(1); + + client.scrollback(room).done(function() { + expect(room.timeline.length).toEqual(5); + const joinMsg = room.timeline[0]; + expect(joinMsg.sender.name).toEqual("Old Alice"); + const oldMsg = room.timeline[1]; + expect(oldMsg.sender.name).toEqual("Old Alice"); + const newMsg = room.timeline[3]; + expect(newMsg.sender.name).toEqual(userName); + + // still have a sync to flush + httpBackend.flush("/sync", 1).then(() => { + done(); + }); + }); + + httpBackend.flush("/messages", 1); + }); + httpBackend.flush("/sync", 1); + }); + + it("should add it them to the right place in the timeline", function(done) { + // set the list of events to return on scrollback + sbEvents = [ + utils.mkMessage({ + user: userId, room: roomId, msg: "I am new", + }), + utils.mkMessage({ + user: userId, room: roomId, msg: "I am old", + }), + ]; + + client.on("sync", function(state) { + if (state !== "PREPARED") { + return; + } + const room = client.getRoom(roomId); + expect(room.timeline.length).toEqual(1); + + client.scrollback(room).done(function() { + expect(room.timeline.length).toEqual(3); + expect(room.timeline[0].event).toEqual(sbEvents[1]); + expect(room.timeline[1].event).toEqual(sbEvents[0]); + + // still have a sync to flush + httpBackend.flush("/sync", 1).then(() => { + done(); + }); + }); + + httpBackend.flush("/messages", 1); + }); + httpBackend.flush("/sync", 1); + }); + + it("should use 'end' as the next pagination token", function(done) { + // set the list of events to return on scrollback + sbEvents = [ + utils.mkMessage({ + user: userId, room: roomId, msg: "I am new", + }), + ]; + + client.on("sync", function(state) { + if (state !== "PREPARED") { + return; + } + const room = client.getRoom(roomId); + expect(room.oldState.paginationToken).toBeTruthy(); + + client.scrollback(room, 1).done(function() { + expect(room.oldState.paginationToken).toEqual(sbEndTok); + }); + + httpBackend.flush("/messages", 1).done(function() { + // still have a sync to flush + httpBackend.flush("/sync", 1).then(() => { + done(); + }); + }); + }); + httpBackend.flush("/sync", 1); + }); + }); + + describe("new events", function() { + it("should be added to the right place in the timeline", function() { + const eventData = [ + utils.mkMessage({user: userId, room: roomId}), + utils.mkMessage({user: userId, room: roomId}), + ]; + setNextSyncData(eventData); + + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(() => { + const room = client.getRoom(roomId); + + let index = 0; + client.on("Room.timeline", function(event, rm, toStart) { + expect(toStart).toBe(false); + expect(rm).toEqual(room); + expect(event.event).toEqual(eventData[index]); + index += 1; + }); + + httpBackend.flush("/messages", 1); + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(function() { + expect(index).toEqual(2); + expect(room.timeline.length).toEqual(3); + expect(room.timeline[2].event).toEqual( + eventData[1], + ); + expect(room.timeline[1].event).toEqual( + eventData[0], + ); + }); + }); + }); + + it("should set the right event.sender values", function() { + const eventData = [ + utils.mkMessage({user: userId, room: roomId}), + utils.mkMembership({ + user: userId, room: roomId, mship: "join", name: "New Name", + }), + utils.mkMessage({user: userId, room: roomId}), + ]; + eventData[1].__prev_event = USER_MEMBERSHIP_EVENT; + setNextSyncData(eventData); + + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(() => { + const room = client.getRoom(roomId); + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(function() { + const preNameEvent = room.timeline[room.timeline.length - 3]; + const postNameEvent = room.timeline[room.timeline.length - 1]; + expect(preNameEvent.sender.name).toEqual(userName); + expect(postNameEvent.sender.name).toEqual("New Name"); + }); + }); + }); + + it("should set the right room.name", function() { + const secondRoomNameEvent = utils.mkEvent({ + user: userId, room: roomId, type: "m.room.name", content: { + name: "Room 2", + }, + }); + secondRoomNameEvent.__prev_event = ROOM_NAME_EVENT; + setNextSyncData([secondRoomNameEvent]); + + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(() => { + const room = client.getRoom(roomId); + let nameEmitCount = 0; + client.on("Room.name", function(rm) { + nameEmitCount += 1; + }); + + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(function() { + expect(nameEmitCount).toEqual(1); + expect(room.name).toEqual("Room 2"); + // do another round + const thirdRoomNameEvent = utils.mkEvent({ + user: userId, room: roomId, type: "m.room.name", content: { + name: "Room 3", + }, + }); + thirdRoomNameEvent.__prev_event = secondRoomNameEvent; + setNextSyncData([thirdRoomNameEvent]); + httpBackend.when("GET", "/sync").respond(200, NEXT_SYNC_DATA); + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]); + }).then(function() { + expect(nameEmitCount).toEqual(2); + expect(room.name).toEqual("Room 3"); + }); + }); + }); + + it("should set the right room members", function() { + const userC = "@cee:bar"; + const userD = "@dee:bar"; + const eventData = [ + utils.mkMembership({ + user: userC, room: roomId, mship: "join", name: "C", + }), + utils.mkMembership({ + user: userC, room: roomId, mship: "invite", skey: userD, + }), + ]; + eventData[0].__prev_event = null; + eventData[1].__prev_event = null; + setNextSyncData(eventData); + + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(() => { + const room = client.getRoom(roomId); + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(function() { + expect(room.currentState.getMembers().length).toEqual(4); + expect(room.currentState.getMember(userC).name).toEqual("C"); + expect(room.currentState.getMember(userC).membership).toEqual( + "join", + ); + expect(room.currentState.getMember(userD).name).toEqual(userD); + expect(room.currentState.getMember(userD).membership).toEqual( + "invite", + ); + }); + }); + }); + }); + + describe("gappy sync", function() { + it("should copy the last known state to the new timeline", function() { + const eventData = [ + utils.mkMessage({user: userId, room: roomId}), + ]; + setNextSyncData(eventData); + NEXT_SYNC_DATA.rooms.join[roomId].timeline.limited = true; + + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(() => { + const room = client.getRoom(roomId); + + httpBackend.flush("/messages", 1); + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(function() { + expect(room.timeline.length).toEqual(1); + expect(room.timeline[0].event).toEqual(eventData[0]); + expect(room.currentState.getMembers().length).toEqual(2); + expect(room.currentState.getMember(userId).name).toEqual(userName); + expect(room.currentState.getMember(userId).membership).toEqual( + "join", + ); + expect(room.currentState.getMember(otherUserId).name).toEqual("Bob"); + expect(room.currentState.getMember(otherUserId).membership).toEqual( + "join", + ); + }); + }); + }); + + it("should emit a 'Room.timelineReset' event", function() { + const eventData = [ + utils.mkMessage({user: userId, room: roomId}), + ]; + setNextSyncData(eventData); + NEXT_SYNC_DATA.rooms.join[roomId].timeline.limited = true; + + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(() => { + const room = client.getRoom(roomId); + + let emitCount = 0; + client.on("Room.timelineReset", function(emitRoom) { + expect(emitRoom).toEqual(room); + emitCount++; + }); + + httpBackend.flush("/messages", 1); + return Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]).then(function() { + expect(emitCount).toEqual(1); + }); + }); + }); + }); +}); diff --git a/matrix-js-sdk/spec/integ/matrix-client-syncing.spec.js b/matrix-js-sdk/spec/integ/matrix-client-syncing.spec.js new file mode 100644 index 000000000..2d702c8a7 --- /dev/null +++ b/matrix-js-sdk/spec/integ/matrix-client-syncing.spec.js @@ -0,0 +1,768 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const HttpBackend = require("matrix-mock-request"); +const utils = require("../test-utils"); +const MatrixEvent = sdk.MatrixEvent; +const EventTimeline = sdk.EventTimeline; + +import expect from 'expect'; +import Promise from 'bluebird'; + +describe("MatrixClient syncing", function() { + const baseUrl = "http://localhost.or.something"; + let client = null; + let httpBackend = null; + const selfUserId = "@alice:localhost"; + const selfAccessToken = "aseukfgwef"; + const otherUserId = "@bob:localhost"; + const userA = "@alice:bar"; + const userB = "@bob:bar"; + const userC = "@claire:bar"; + const roomOne = "!foo:localhost"; + const roomTwo = "!bar:localhost"; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + httpBackend = new HttpBackend(); + sdk.request(httpBackend.requestFn); + client = sdk.createClient({ + baseUrl: baseUrl, + userId: selfUserId, + accessToken: selfAccessToken, + }); + httpBackend.when("GET", "/pushrules").respond(200, {}); + httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + }); + + afterEach(function() { + httpBackend.verifyNoOutstandingExpectation(); + client.stopClient(); + return httpBackend.stop(); + }); + + describe("startClient", function() { + const syncData = { + next_batch: "batch_token", + rooms: {}, + presence: {}, + }; + + it("should /sync after /pushrules and /filter.", function(done) { + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + + httpBackend.flushAllExpected().done(function() { + done(); + }); + }); + + it("should pass the 'next_batch' token from /sync to the since= param " + + " of the next /sync", function(done) { + httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend.when("GET", "/sync").check(function(req) { + expect(req.queryParams.since).toEqual(syncData.next_batch); + }).respond(200, syncData); + + client.startClient(); + + httpBackend.flushAllExpected().done(function() { + done(); + }); + }); + }); + + describe("resolving invites to profile info", function() { + const syncData = { + next_batch: "s_5_3", + presence: { + events: [], + }, + rooms: { + join: { + + }, + }, + }; + + beforeEach(function() { + syncData.presence.events = []; + syncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }), + ], + }, + state: { + events: [ + utils.mkMembership({ + room: roomOne, mship: "join", user: otherUserId, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: selfUserId, + }), + utils.mkEvent({ + type: "m.room.create", room: roomOne, user: selfUserId, + content: { + creator: selfUserId, + }, + }), + ], + }, + }; + }); + + it("should resolve incoming invites from /sync", function() { + syncData.rooms.join[roomOne].state.events.push( + utils.mkMembership({ + room: roomOne, mship: "invite", user: userC, + }), + ); + + httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend.when("GET", "/profile/" + encodeURIComponent(userC)).respond( + 200, { + avatar_url: "mxc://flibble/wibble", + displayname: "The Boss", + }, + ); + + client.startClient({ + resolveInvitesToProfiles: true, + }); + + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]).then(function() { + const member = client.getRoom(roomOne).getMember(userC); + expect(member.name).toEqual("The Boss"); + expect( + member.getAvatarUrl("home.server.url", null, null, null, false), + ).toBeTruthy(); + }); + }); + + it("should use cached values from m.presence wherever possible", function() { + syncData.presence.events = [ + utils.mkPresence({ + user: userC, presence: "online", name: "The Ghost", + }), + ]; + syncData.rooms.join[roomOne].state.events.push( + utils.mkMembership({ + room: roomOne, mship: "invite", user: userC, + }), + ); + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient({ + resolveInvitesToProfiles: true, + }); + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]).then(function() { + const member = client.getRoom(roomOne).getMember(userC); + expect(member.name).toEqual("The Ghost"); + }); + }); + + it("should result in events on the room member firing", function() { + syncData.presence.events = [ + utils.mkPresence({ + user: userC, presence: "online", name: "The Ghost", + }), + ]; + syncData.rooms.join[roomOne].state.events.push( + utils.mkMembership({ + room: roomOne, mship: "invite", user: userC, + }), + ); + + httpBackend.when("GET", "/sync").respond(200, syncData); + + let latestFiredName = null; + client.on("RoomMember.name", function(event, m) { + if (m.userId === userC && m.roomId === roomOne) { + latestFiredName = m.name; + } + }); + + client.startClient({ + resolveInvitesToProfiles: true, + }); + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]).then(function() { + expect(latestFiredName).toEqual("The Ghost"); + }); + }); + + it("should no-op if resolveInvitesToProfiles is not set", function() { + syncData.rooms.join[roomOne].state.events.push( + utils.mkMembership({ + room: roomOne, mship: "invite", user: userC, + }), + ); + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]).then(function() { + const member = client.getRoom(roomOne).getMember(userC); + expect(member.name).toEqual(userC); + expect( + member.getAvatarUrl("home.server.url", null, null, null, false), + ).toBe(null); + }); + }); + }); + + describe("users", function() { + const syncData = { + next_batch: "nb", + presence: { + events: [ + utils.mkPresence({ + user: userA, presence: "online", + }), + utils.mkPresence({ + user: userB, presence: "unavailable", + }), + ], + }, + }; + + it("should create users for presence events from /sync", + function() { + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]).then(function() { + expect(client.getUser(userA).presence).toEqual("online"); + expect(client.getUser(userB).presence).toEqual("unavailable"); + }); + }); + }); + + describe("room state", function() { + const msgText = "some text here"; + const otherDisplayName = "Bob Smith"; + + const syncData = { + rooms: { + join: { + + }, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }), + ], + }, + state: { + events: [ + utils.mkEvent({ + type: "m.room.name", room: roomOne, user: otherUserId, + content: { + name: "Old room name", + }, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: otherUserId, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: selfUserId, + }), + utils.mkEvent({ + type: "m.room.create", room: roomOne, user: selfUserId, + content: { + creator: selfUserId, + }, + }), + ], + }, + }; + syncData.rooms.join[roomTwo] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomTwo, user: otherUserId, msg: "hiii", + }), + ], + }, + state: { + events: [ + utils.mkMembership({ + room: roomTwo, mship: "join", user: otherUserId, + name: otherDisplayName, + }), + utils.mkMembership({ + room: roomTwo, mship: "join", user: selfUserId, + }), + utils.mkEvent({ + type: "m.room.create", room: roomTwo, user: selfUserId, + content: { + creator: selfUserId, + }, + }), + ], + }, + }; + + const nextSyncData = { + rooms: { + join: { + + }, + }, + }; + + nextSyncData.rooms.join[roomOne] = { + state: { + events: [ + utils.mkEvent({ + type: "m.room.name", room: roomOne, user: selfUserId, + content: { name: "A new room name" }, + }), + ], + }, + }; + + nextSyncData.rooms.join[roomTwo] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomTwo, user: otherUserId, msg: msgText, + }), + ], + }, + ephemeral: { + events: [ + utils.mkEvent({ + type: "m.typing", room: roomTwo, + content: { user_ids: [otherUserId] }, + }), + ], + }, + }; + + it("should continually recalculate the right room name.", function() { + httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + + client.startClient(); + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(2), + ]).then(function() { + const room = client.getRoom(roomOne); + // should have clobbered the name to the one from /events + expect(room.name).toEqual( + nextSyncData.rooms.join[roomOne].state.events[0].content.name, + ); + }); + }); + + it("should store the right events in the timeline.", function() { + httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + + client.startClient(); + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(2), + ]).then(function() { + const room = client.getRoom(roomTwo); + // should have added the message from /events + expect(room.timeline.length).toEqual(2); + expect(room.timeline[1].getContent().body).toEqual(msgText); + }); + }); + + it("should set the right room name.", function() { + httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + + client.startClient(); + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(2), + ]).then(function() { + const room = client.getRoom(roomTwo); + // should use the display name of the other person. + expect(room.name).toEqual(otherDisplayName); + }); + }); + + it("should set the right user's typing flag.", function() { + httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + + client.startClient(); + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(2), + ]).then(function() { + const room = client.getRoom(roomTwo); + let member = room.getMember(otherUserId); + expect(member).toBeTruthy(); + expect(member.typing).toEqual(true); + member = room.getMember(selfUserId); + expect(member).toBeTruthy(); + expect(member.typing).toEqual(false); + }); + }); + + // XXX: This test asserts that the js-sdk obeys the spec and treats state + // events that arrive in the incremental sync as if they preceeded the + // timeline events, however this breaks peeking, so it's disabled + // (see sync.js) + xit("should correctly interpret state in incremental sync.", function() { + httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + + client.startClient(); + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(2), + ]).then(function() { + const room = client.getRoom(roomOne); + const stateAtStart = room.getLiveTimeline().getState( + EventTimeline.BACKWARDS, + ); + const startRoomNameEvent = stateAtStart.getStateEvents('m.room.name', ''); + expect(startRoomNameEvent.getContent().name).toEqual('Old room name'); + + const stateAtEnd = room.getLiveTimeline().getState( + EventTimeline.FORWARDS, + ); + const endRoomNameEvent = stateAtEnd.getStateEvents('m.room.name', ''); + expect(endRoomNameEvent.getContent().name).toEqual('A new room name'); + }); + }); + + xit("should update power levels for users in a room", function() { + + }); + + xit("should update the room topic", function() { + + }); + }); + + describe("timeline", function() { + beforeEach(function() { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }), + ], + prev_batch: "pagTok", + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + }); + + it("should set the back-pagination token on new rooms", function() { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomTwo] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomTwo, user: otherUserId, msg: "roomtwo", + }), + ], + prev_batch: "roomtwotok", + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]).then(function() { + const room = client.getRoom(roomTwo); + expect(room).toExist(); + const tok = room.getLiveTimeline() + .getPaginationToken(EventTimeline.BACKWARDS); + expect(tok).toEqual("roomtwotok"); + }); + }); + + it("should set the back-pagination token on gappy syncs", function() { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "world", + }), + ], + limited: true, + prev_batch: "newerTok", + }, + }; + httpBackend.when("GET", "/sync").respond(200, syncData); + + let resetCallCount = 0; + // the token should be set *before* timelineReset is emitted + client.on("Room.timelineReset", function(room) { + resetCallCount++; + + const tl = room.getLiveTimeline(); + expect(tl.getEvents().length).toEqual(0); + const tok = tl.getPaginationToken(EventTimeline.BACKWARDS); + expect(tok).toEqual("newerTok"); + }); + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]).then(function() { + const room = client.getRoom(roomOne); + const tl = room.getLiveTimeline(); + expect(tl.getEvents().length).toEqual(1); + expect(resetCallCount).toEqual(1); + }); + }); + }); + + describe("receipts", function() { + const syncData = { + rooms: { + join: { + + }, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }), + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "world", + }), + ], + }, + state: { + events: [ + utils.mkEvent({ + type: "m.room.name", room: roomOne, user: otherUserId, + content: { + name: "Old room name", + }, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: otherUserId, + }), + utils.mkMembership({ + room: roomOne, mship: "join", user: selfUserId, + }), + utils.mkEvent({ + type: "m.room.create", room: roomOne, user: selfUserId, + content: { + creator: selfUserId, + }, + }), + ], + }, + }; + + beforeEach(function() { + syncData.rooms.join[roomOne].ephemeral = { + events: [], + }; + }); + + it("should sync receipts from /sync.", function() { + const ackEvent = syncData.rooms.join[roomOne].timeline.events[0]; + const receipt = {}; + receipt[ackEvent.event_id] = { + "m.read": {}, + }; + receipt[ackEvent.event_id]["m.read"][userC] = { + ts: 176592842636, + }; + syncData.rooms.join[roomOne].ephemeral.events = [{ + content: receipt, + room_id: roomOne, + type: "m.receipt", + }]; + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + + return Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]).then(function() { + const room = client.getRoom(roomOne); + expect(room.getReceiptsForEvent(new MatrixEvent(ackEvent))).toEqual([{ + type: "m.read", + userId: userC, + data: { + ts: 176592842636, + }, + }]); + }); + }); + }); + + describe("of a room", function() { + xit("should sync when a join event (which changes state) for the user" + + " arrives down the event stream (e.g. join from another device)", function() { + + }); + + xit("should sync when the user explicitly calls joinRoom", function() { + + }); + }); + + describe("syncLeftRooms", function() { + beforeEach(function(done) { + client.startClient(); + + httpBackend.flushAllExpected().then(function() { + // the /sync call from syncLeftRooms ends up in the request + // queue behind the call from the running client; add a response + // to flush the client's one out. + httpBackend.when("GET", "/sync").respond(200, {}); + + done(); + }); + }); + + it("should create and use an appropriate filter", function() { + httpBackend.when("POST", "/filter").check(function(req) { + expect(req.data).toEqual({ + room: { timeline: {limit: 1}, + include_leave: true }}); + }).respond(200, { filter_id: "another_id" }); + + const defer = Promise.defer(); + + httpBackend.when("GET", "/sync").check(function(req) { + expect(req.queryParams.filter).toEqual("another_id"); + defer.resolve(); + }).respond(200, {}); + + client.syncLeftRooms(); + + // first flush the filter request; this will make syncLeftRooms + // make its /sync call + return Promise.all([ + httpBackend.flush("/filter").then(function() { + // flush the syncs + return httpBackend.flushAllExpected(); + }), + defer.promise, + ]); + }); + + it("should set the back-pagination token on left rooms", function() { + const syncData = { + next_batch: "batch_token", + rooms: { + leave: {}, + }, + }; + + syncData.rooms.leave[roomTwo] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomTwo, user: otherUserId, msg: "hello", + }), + ], + prev_batch: "pagTok", + }, + }; + + httpBackend.when("POST", "/filter").respond(200, { + filter_id: "another_id", + }); + + httpBackend.when("GET", "/sync").respond(200, syncData); + + return Promise.all([ + client.syncLeftRooms().then(function() { + const room = client.getRoom(roomTwo); + const tok = room.getLiveTimeline().getPaginationToken( + EventTimeline.BACKWARDS); + + expect(tok).toEqual("pagTok"); + }), + + // first flush the filter request; this will make syncLeftRooms + // make its /sync call + httpBackend.flush("/filter").then(function() { + return httpBackend.flushAllExpected(); + }), + ]); + }); + }); + + /** + * waits for the MatrixClient to emit one or more 'sync' events. + * + * @param {Number?} numSyncs number of syncs to wait for + * @returns {Promise} promise which resolves after the sync events have happened + */ + function awaitSyncEvent(numSyncs) { + return utils.syncPromise(client, numSyncs); + } +}); diff --git a/matrix-js-sdk/spec/integ/megolm-integ.spec.js b/matrix-js-sdk/spec/integ/megolm-integ.spec.js new file mode 100644 index 000000000..9456aa944 --- /dev/null +++ b/matrix-js-sdk/spec/integ/megolm-integ.spec.js @@ -0,0 +1,974 @@ +/* +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. +*/ + +"use strict"; + +const anotherjson = require('another-json'); +import Promise from 'bluebird'; +import expect from 'expect'; + +const utils = require('../../lib/utils'); +const testUtils = require('../test-utils'); +const TestClient = require('../TestClient').default; +import logger from '../../src/logger'; + +const ROOM_ID = "!room:id"; + +/** + * start an Olm session with a given recipient + * + * @param {Olm.Account} olmAccount + * @param {TestClient} recipientTestClient + * @return {Promise} promise for Olm.Session + */ +function createOlmSession(olmAccount, recipientTestClient) { + return recipientTestClient.awaitOneTimeKeyUpload().then((keys) => { + const otkId = utils.keys(keys)[0]; + const otk = keys[otkId]; + + const session = new global.Olm.Session(); + session.create_outbound( + olmAccount, recipientTestClient.getDeviceKey(), otk.key, + ); + return session; + }); +} + +/** + * encrypt an event with olm + * + * @param {object} opts + * @param {string=} opts.sender + * @param {string} opts.senderKey + * @param {Olm.Session} opts.p2pSession + * @param {TestClient} opts.recipient + * @param {object=} opts.plaincontent + * @param {string=} opts.plaintype + * + * @return {object} event + */ +function encryptOlmEvent(opts) { + expect(opts.senderKey).toBeTruthy(); + expect(opts.p2pSession).toBeTruthy(); + expect(opts.recipient).toBeTruthy(); + + const plaintext = { + content: opts.plaincontent || {}, + recipient: opts.recipient.userId, + recipient_keys: { + ed25519: opts.recipient.getSigningKey(), + }, + sender: opts.sender || '@bob:xyz', + type: opts.plaintype || 'm.test', + }; + + const event = { + content: { + algorithm: 'm.olm.v1.curve25519-aes-sha2', + ciphertext: {}, + sender_key: opts.senderKey, + }, + sender: opts.sender || '@bob:xyz', + type: 'm.room.encrypted', + }; + event.content.ciphertext[opts.recipient.getDeviceKey()] = + opts.p2pSession.encrypt(JSON.stringify(plaintext)); + return event; +} + +/** + * encrypt an event with megolm + * + * @param {object} opts + * @param {string} opts.senderKey + * @param {Olm.OutboundGroupSession} opts.groupSession + * @param {object=} opts.plaintext + * @param {string=} opts.room_id + * + * @return {object} event + */ +function encryptMegolmEvent(opts) { + expect(opts.senderKey).toBeTruthy(); + expect(opts.groupSession).toBeTruthy(); + + const plaintext = opts.plaintext || {}; + if (!plaintext.content) { + plaintext.content = { + body: '42', + msgtype: "m.text", + }; + } + if (!plaintext.type) { + plaintext.type = "m.room.message"; + } + if (!plaintext.room_id) { + expect(opts.room_id).toBeTruthy(); + plaintext.room_id = opts.room_id; + } + + return { + event_id: 'test_megolm_event', + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: opts.groupSession.encrypt(JSON.stringify(plaintext)), + device_id: "testDevice", + sender_key: opts.senderKey, + session_id: opts.groupSession.session_id(), + }, + type: "m.room.encrypted", + }; +} + +/** + * build an encrypted room_key event to share a group session + * + * @param {object} opts + * @param {string} opts.senderKey + * @param {TestClient} opts.recipient + * @param {Olm.Session} opts.p2pSession + * @param {Olm.OutboundGroupSession} opts.groupSession + * @param {string=} opts.room_id + * + * @return {object} event + */ +function encryptGroupSessionKey(opts) { + return encryptOlmEvent({ + senderKey: opts.senderKey, + recipient: opts.recipient, + p2pSession: opts.p2pSession, + plaincontent: { + algorithm: 'm.megolm.v1.aes-sha2', + room_id: opts.room_id, + session_id: opts.groupSession.session_id(), + session_key: opts.groupSession.session_key(), + }, + plaintype: 'm.room_key', + }); +} + +/** + * get a /sync response which contains a single room (ROOM_ID), + * with the members given + * + * @param {string[]} roomMembers + * + * @return {object} event + */ +function getSyncResponse(roomMembers) { + const roomResponse = { + state: { + events: [ + testUtils.mkEvent({ + type: 'm.room.encryption', + skey: '', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + }, + }), + ], + }, + }; + + for (let i = 0; i < roomMembers.length; i++) { + roomResponse.state.events.push( + testUtils.mkMembership({ + mship: 'join', + sender: roomMembers[i], + }), + ); + } + + const syncResponse = { + next_batch: 1, + rooms: { + join: {}, + }, + }; + syncResponse.rooms.join[ROOM_ID] = roomResponse; + return syncResponse; +} + + +describe("megolm", function() { + if (!global.Olm) { + logger.warn('not running megolm tests: Olm not present'); + return; + } + const Olm = global.Olm; + + let testOlmAccount; + let testSenderKey; + let aliceTestClient; + + /** + * Get the device keys for testOlmAccount in a format suitable for a + * response to /keys/query + * + * @param {string} userId The user ID to query for + * @returns {Object} The fake query response + */ + function getTestKeysQueryResponse(userId) { + const testE2eKeys = JSON.parse(testOlmAccount.identity_keys()); + const testDeviceKeys = { + algorithms: ['m.olm.v1.curve25519-aes-sha2', 'm.megolm.v1.aes-sha2'], + device_id: 'DEVICE_ID', + keys: { + 'curve25519:DEVICE_ID': testE2eKeys.curve25519, + 'ed25519:DEVICE_ID': testE2eKeys.ed25519, + }, + user_id: userId, + }; + const j = anotherjson.stringify(testDeviceKeys); + const sig = testOlmAccount.sign(j); + testDeviceKeys.signatures = {}; + testDeviceKeys.signatures[userId] = { + 'ed25519:DEVICE_ID': sig, + }; + + const queryResponse = { + device_keys: {}, + }; + + queryResponse.device_keys[userId] = { + 'DEVICE_ID': testDeviceKeys, + }; + + return queryResponse; + } + + /** + * Get a one-time key for testOlmAccount in a format suitable for a + * response to /keys/claim + + * @param {string} userId The user ID to query for + * @returns {Object} The fake key claim response + */ + function getTestKeysClaimResponse(userId) { + testOlmAccount.generate_one_time_keys(1); + const testOneTimeKeys = JSON.parse(testOlmAccount.one_time_keys()); + testOlmAccount.mark_keys_as_published(); + + const keyId = utils.keys(testOneTimeKeys.curve25519)[0]; + const oneTimeKey = testOneTimeKeys.curve25519[keyId]; + const keyResult = { + 'key': oneTimeKey, + }; + const j = anotherjson.stringify(keyResult); + const sig = testOlmAccount.sign(j); + keyResult.signatures = {}; + keyResult.signatures[userId] = { + 'ed25519:DEVICE_ID': sig, + }; + + const claimResponse = {one_time_keys: {}}; + claimResponse.one_time_keys[userId] = { + 'DEVICE_ID': {}, + }; + claimResponse.one_time_keys[userId].DEVICE_ID['signed_curve25519:' + keyId] = + keyResult; + return claimResponse; + } + + beforeEach(async function() { + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + + aliceTestClient = new TestClient( + "@alice:localhost", "xzcvb", "akjgkrgjs", + ); + await aliceTestClient.client.initCrypto(); + + testOlmAccount = new Olm.Account(); + testOlmAccount.create(); + const testE2eKeys = JSON.parse(testOlmAccount.identity_keys()); + testSenderKey = testE2eKeys.curve25519; + }); + + afterEach(function() { + return aliceTestClient.stop(); + }); + + it("Alice receives a megolm message", function() { + return aliceTestClient.start().then(() => { + return createOlmSession(testOlmAccount, aliceTestClient); + }).then((p2pSession) => { + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + + // make the room_key event + const roomKeyEncrypted = encryptGroupSessionKey({ + senderKey: testSenderKey, + recipient: aliceTestClient, + p2pSession: p2pSession, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // encrypt a message with the group session + const messageEncrypted = encryptMegolmEvent({ + senderKey: testSenderKey, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // Alice gets both the events in a single sync + const syncResponse = { + next_batch: 1, + to_device: { + events: [roomKeyEncrypted], + }, + rooms: { + join: {}, + }, + }; + syncResponse.rooms.join[ROOM_ID] = { + timeline: { + events: [messageEncrypted], + }, + }; + + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); + return aliceTestClient.flushSync(); + }).then(function() { + const room = aliceTestClient.client.getRoom(ROOM_ID); + const event = room.getLiveTimeline().getEvents()[0]; + expect(event.isEncrypted()).toBe(true); + return testUtils.awaitDecryption(event); + }).then((event) => { + expect(event.getContent().body).toEqual('42'); + }); + }); + + it("Alice receives a megolm message before the session keys", function() { + // https://github.com/vector-im/riot-web/issues/2273 + let roomKeyEncrypted; + + return aliceTestClient.start().then(() => { + return createOlmSession(testOlmAccount, aliceTestClient); + }).then((p2pSession) => { + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + + // make the room_key event, but don't send it yet + roomKeyEncrypted = encryptGroupSessionKey({ + senderKey: testSenderKey, + recipient: aliceTestClient, + p2pSession: p2pSession, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // encrypt a message with the group session + const messageEncrypted = encryptMegolmEvent({ + senderKey: testSenderKey, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // Alice just gets the message event to start with + const syncResponse = { + next_batch: 1, + rooms: { + join: {}, + }, + }; + syncResponse.rooms.join[ROOM_ID] = { + timeline: { + events: [messageEncrypted], + }, + }; + + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); + return aliceTestClient.flushSync(); + }).then(function() { + const room = aliceTestClient.client.getRoom(ROOM_ID); + const event = room.getLiveTimeline().getEvents()[0]; + expect(event.getContent().msgtype).toEqual('m.bad.encrypted'); + + // now she gets the room_key event + const syncResponse = { + next_batch: 2, + to_device: { + events: [roomKeyEncrypted], + }, + }; + + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); + return aliceTestClient.flushSync(); + }).then(function() { + const room = aliceTestClient.client.getRoom(ROOM_ID); + const event = room.getLiveTimeline().getEvents()[0]; + + if (event.getContent().msgtype != 'm.bad.encrypted') { + return event; + } + + return new Promise((resolve, reject) => { + event.once('Event.decrypted', (ev) => { + logger.log(`${Date.now()} event ${event.getId()} now decrypted`); + resolve(ev); + }); + }); + }).then((event) => { + expect(event.getContent().body).toEqual('42'); + }); + }); + + it("Alice gets a second room_key message", function() { + return aliceTestClient.start().then(() => { + return createOlmSession(testOlmAccount, aliceTestClient); + }).then((p2pSession) => { + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + + // make the room_key event + const roomKeyEncrypted1 = encryptGroupSessionKey({ + senderKey: testSenderKey, + recipient: aliceTestClient, + p2pSession: p2pSession, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // encrypt a message with the group session + const messageEncrypted = encryptMegolmEvent({ + senderKey: testSenderKey, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // make a second room_key event now that we have advanced the group + // session. + const roomKeyEncrypted2 = encryptGroupSessionKey({ + senderKey: testSenderKey, + recipient: aliceTestClient, + p2pSession: p2pSession, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // on the first sync, send the best room key + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, { + next_batch: 1, + to_device: { + events: [roomKeyEncrypted1], + }, + }); + + // on the second sync, send the advanced room key, along with the + // message. This simulates the situation where Alice has been sent a + // later copy of the room key and is reloading the client. + const syncResponse2 = { + next_batch: 2, + to_device: { + events: [roomKeyEncrypted2], + }, + rooms: { + join: {}, + }, + }; + syncResponse2.rooms.join[ROOM_ID] = { + timeline: { + events: [messageEncrypted], + }, + }; + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse2); + + // flush both syncs + return aliceTestClient.flushSync().then(() => { + return aliceTestClient.flushSync(); + }); + }).then(function() { + const room = aliceTestClient.client.getRoom(ROOM_ID); + const event = room.getLiveTimeline().getEvents()[0]; + expect(event.getContent().body).toEqual('42'); + }); + }); + + it('Alice sends a megolm message', function() { + let p2pSession; + + aliceTestClient.expectKeyQuery({device_keys: {'@alice:localhost': []}}); + return aliceTestClient.start().then(() => { + // establish an olm session with alice + return createOlmSession(testOlmAccount, aliceTestClient); + }).then((_p2pSession) => { + p2pSession = _p2pSession; + + const syncResponse = getSyncResponse(['@bob:xyz']); + + const olmEvent = encryptOlmEvent({ + senderKey: testSenderKey, + recipient: aliceTestClient, + p2pSession: p2pSession, + }); + + syncResponse.to_device = { events: [olmEvent] }; + + aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + return aliceTestClient.flushSync(); + }).then(function() { + // start out with the device unknown - the send should be rejected. + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, getTestKeysQueryResponse('@bob:xyz'), + ); + + return Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, 'test').then(() => { + throw new Error("sendTextMessage failed on an unknown device"); + }, (e) => { + expect(e.name).toEqual("UnknownDeviceError"); + }), + aliceTestClient.httpBackend.flushAllExpected(), + ]); + }).then(function() { + // mark the device as known, and resend. + aliceTestClient.client.setDeviceKnown('@bob:xyz', 'DEVICE_ID'); + + let inboundGroupSession; + aliceTestClient.httpBackend.when( + 'PUT', '/sendToDevice/m.room.encrypted/', + ).respond(200, function(path, content) { + const m = content.messages['@bob:xyz'].DEVICE_ID; + const ct = m.ciphertext[testSenderKey]; + const decrypted = JSON.parse(p2pSession.decrypt(ct.type, ct.body)); + + expect(decrypted.type).toEqual('m.room_key'); + inboundGroupSession = new Olm.InboundGroupSession(); + inboundGroupSession.create(decrypted.content.session_key); + return {}; + }); + + aliceTestClient.httpBackend.when( + 'PUT', '/send/', + ).respond(200, function(path, content) { + const ct = content.ciphertext; + const r = inboundGroupSession.decrypt(ct); + logger.log('Decrypted received megolm message', r); + + expect(r.message_index).toEqual(0); + const decrypted = JSON.parse(r.plaintext); + expect(decrypted.type).toEqual('m.room.message'); + expect(decrypted.content.body).toEqual('test'); + + return { + event_id: '$event_id', + }; + }); + + const room = aliceTestClient.client.getRoom(ROOM_ID); + const pendingMsg = room.getPendingEvents()[0]; + + return Promise.all([ + aliceTestClient.client.resendEvent(pendingMsg, room), + + // the crypto stuff can take a while, so give the requests a whole second. + aliceTestClient.httpBackend.flushAllExpected({ + timeout: 1000, + }), + ]); + }); + }); + + it("We shouldn't attempt to send to blocked devices", function() { + aliceTestClient.expectKeyQuery({device_keys: {'@alice:localhost': {}}}); + return aliceTestClient.start().then(() => { + // establish an olm session with alice + return createOlmSession(testOlmAccount, aliceTestClient); + }).then((p2pSession) => { + const syncResponse = getSyncResponse(['@bob:xyz']); + + const olmEvent = encryptOlmEvent({ + senderKey: testSenderKey, + recipient: aliceTestClient, + p2pSession: p2pSession, + }); + + syncResponse.to_device = { events: [olmEvent] }; + aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + + return aliceTestClient.flushSync(); + }).then(function() { + logger.log('Forcing alice to download our device keys'); + + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, getTestKeysQueryResponse('@bob:xyz'), + ); + + return Promise.all([ + aliceTestClient.client.downloadKeys(['@bob:xyz']), + aliceTestClient.httpBackend.flush('/keys/query', 1), + ]); + }).then(function() { + logger.log('Telling alice to block our device'); + aliceTestClient.client.setDeviceBlocked('@bob:xyz', 'DEVICE_ID'); + + logger.log('Telling alice to send a megolm message'); + aliceTestClient.httpBackend.when( + 'PUT', '/send/', + ).respond(200, { + event_id: '$event_id', + }); + + return Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), + + // the crypto stuff can take a while, so give the requests a whole second. + aliceTestClient.httpBackend.flushAllExpected({ + timeout: 1000, + }), + ]); + }); + }); + + it("We should start a new megolm session when a device is blocked", function() { + let p2pSession; + let megolmSessionId; + + aliceTestClient.expectKeyQuery({device_keys: {'@alice:localhost': {}}}); + return aliceTestClient.start().then(() => { + // establish an olm session with alice + return createOlmSession(testOlmAccount, aliceTestClient); + }).then((_p2pSession) => { + p2pSession = _p2pSession; + + const syncResponse = getSyncResponse(['@bob:xyz']); + + const olmEvent = encryptOlmEvent({ + senderKey: testSenderKey, + recipient: aliceTestClient, + p2pSession: p2pSession, + }); + + syncResponse.to_device = { events: [olmEvent] }; + aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + + return aliceTestClient.flushSync(); + }).then(function() { + logger.log("Fetching bob's devices and marking known"); + + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, getTestKeysQueryResponse('@bob:xyz'), + ); + + return Promise.all([ + aliceTestClient.client.downloadKeys(['@bob:xyz']), + aliceTestClient.httpBackend.flushAllExpected(), + ]).then((keys) => { + aliceTestClient.client.setDeviceKnown('@bob:xyz', 'DEVICE_ID'); + }); + }).then(function() { + logger.log('Telling alice to send a megolm message'); + + aliceTestClient.httpBackend.when( + 'PUT', '/sendToDevice/m.room.encrypted/', + ).respond(200, function(path, content) { + logger.log('sendToDevice: ', content); + const m = content.messages['@bob:xyz'].DEVICE_ID; + const ct = m.ciphertext[testSenderKey]; + expect(ct.type).toEqual(1); // normal message + const decrypted = JSON.parse(p2pSession.decrypt(ct.type, ct.body)); + logger.log('decrypted sendToDevice:', decrypted); + expect(decrypted.type).toEqual('m.room_key'); + megolmSessionId = decrypted.content.session_id; + return {}; + }); + + aliceTestClient.httpBackend.when( + 'PUT', '/send/', + ).respond(200, function(path, content) { + logger.log('/send:', content); + expect(content.session_id).toEqual(megolmSessionId); + return { + event_id: '$event_id', + }; + }); + + return Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), + + // the crypto stuff can take a while, so give the requests a whole second. + aliceTestClient.httpBackend.flushAllExpected({ + timeout: 1000, + }), + ]); + }).then(function() { + logger.log('Telling alice to block our device'); + aliceTestClient.client.setDeviceBlocked('@bob:xyz', 'DEVICE_ID'); + + logger.log('Telling alice to send another megolm message'); + aliceTestClient.httpBackend.when( + 'PUT', '/send/', + ).respond(200, function(path, content) { + logger.log('/send:', content); + expect(content.session_id).toNotEqual(megolmSessionId); + return { + event_id: '$event_id', + }; + }); + + return Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, 'test2'), + aliceTestClient.httpBackend.flushAllExpected(), + ]); + }); + }); + + // https://github.com/vector-im/riot-web/issues/2676 + it("Alice should send to her other devices", function() { + // for this test, we make the testOlmAccount be another of Alice's devices. + // it ought to get included in messages Alice sends. + + let p2pSession; + let inboundGroupSession; + let decrypted; + + return aliceTestClient.start().then(function() { + // an encrypted room with just alice + const syncResponse = { + next_batch: 1, + rooms: { + join: {}, + }, + }; + syncResponse.rooms.join[ROOM_ID] = { + state: { + events: [ + testUtils.mkEvent({ + type: 'm.room.encryption', + skey: '', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + }, + }), + testUtils.mkMembership({ + mship: 'join', + sender: aliceTestClient.userId, + }), + ], + }, + }; + aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + + // the completion of the first initialsync hould make Alice + // invalidate the device cache for all members in e2e rooms (ie, + // herself), and do a key query. + aliceTestClient.expectKeyQuery( + getTestKeysQueryResponse(aliceTestClient.userId), + ); + + return aliceTestClient.httpBackend.flushAllExpected(); + }).then(function() { + // start out with the device unknown - the send should be rejected. + return aliceTestClient.client.sendTextMessage(ROOM_ID, 'test').then(() => { + throw new Error("sendTextMessage failed on an unknown device"); + }, (e) => { + expect(e.name).toEqual("UnknownDeviceError"); + expect(Object.keys(e.devices)).toEqual([aliceTestClient.userId]); + expect(Object.keys(e.devices[aliceTestClient.userId])). + toEqual(['DEVICE_ID']); + }); + }).then(function() { + // mark the device as known, and resend. + aliceTestClient.client.setDeviceKnown(aliceTestClient.userId, 'DEVICE_ID'); + aliceTestClient.httpBackend.when('POST', '/keys/claim').respond( + 200, function(path, content) { + expect(content.one_time_keys[aliceTestClient.userId].DEVICE_ID) + .toEqual("signed_curve25519"); + return getTestKeysClaimResponse(aliceTestClient.userId); + }); + + aliceTestClient.httpBackend.when( + 'PUT', '/sendToDevice/m.room.encrypted/', + ).respond(200, function(path, content) { + logger.log("sendToDevice: ", content); + const m = content.messages[aliceTestClient.userId].DEVICE_ID; + const ct = m.ciphertext[testSenderKey]; + expect(ct.type).toEqual(0); // pre-key message + + p2pSession = new Olm.Session(); + p2pSession.create_inbound(testOlmAccount, ct.body); + const decrypted = JSON.parse(p2pSession.decrypt(ct.type, ct.body)); + + expect(decrypted.type).toEqual('m.room_key'); + inboundGroupSession = new Olm.InboundGroupSession(); + inboundGroupSession.create(decrypted.content.session_key); + return {}; + }); + + aliceTestClient.httpBackend.when( + 'PUT', '/send/', + ).respond(200, function(path, content) { + const ct = content.ciphertext; + const r = inboundGroupSession.decrypt(ct); + logger.log('Decrypted received megolm message', r); + decrypted = JSON.parse(r.plaintext); + + return { + event_id: '$event_id', + }; + }); + + // Grab the event that we'll need to resend + const room = aliceTestClient.client.getRoom(ROOM_ID); + const pendingEvents = room.getPendingEvents(); + expect(pendingEvents.length).toEqual(1); + const unsentEvent = pendingEvents[0]; + + return Promise.all([ + aliceTestClient.client.resendEvent(unsentEvent, room), + + // the crypto stuff can take a while, so give the requests a whole second. + aliceTestClient.httpBackend.flushAllExpected({ + timeout: 1000, + }), + ]); + }).then(function() { + expect(decrypted.type).toEqual('m.room.message'); + expect(decrypted.content.body).toEqual('test'); + }); + }); + + + it('Alice should wait for device list to complete when sending a megolm message', + function() { + let downloadPromise; + let sendPromise; + + aliceTestClient.expectKeyQuery({device_keys: {'@alice:localhost': {}}}); + return aliceTestClient.start().then(() => { + // establish an olm session with alice + return createOlmSession(testOlmAccount, aliceTestClient); + }).then((p2pSession) => { + const syncResponse = getSyncResponse(['@bob:xyz']); + + const olmEvent = encryptOlmEvent({ + senderKey: testSenderKey, + recipient: aliceTestClient, + p2pSession: p2pSession, + }); + + syncResponse.to_device = { events: [olmEvent] }; + + aliceTestClient.httpBackend.when('GET', '/sync').respond(200, syncResponse); + return aliceTestClient.flushSync(); + }).then(function() { + // this will block + logger.log('Forcing alice to download our device keys'); + downloadPromise = aliceTestClient.client.downloadKeys(['@bob:xyz']); + + // so will this. + sendPromise = aliceTestClient.client.sendTextMessage(ROOM_ID, 'test') + .then(() => { + throw new Error("sendTextMessage failed on an unknown device"); + }, (e) => { + expect(e.name).toEqual("UnknownDeviceError"); + }); + + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, getTestKeysQueryResponse('@bob:xyz'), + ); + + return aliceTestClient.httpBackend.flushAllExpected(); + }).then(function() { + return Promise.all([downloadPromise, sendPromise]); + }); + }); + + + it("Alice exports megolm keys and imports them to a new device", function() { + let messageEncrypted; + + aliceTestClient.expectKeyQuery({device_keys: {'@alice:localhost': {}}}); + return aliceTestClient.start().then(() => { + // establish an olm session with alice + return createOlmSession(testOlmAccount, aliceTestClient); + }).then((p2pSession) => { + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + + // make the room_key event + const roomKeyEncrypted = encryptGroupSessionKey({ + senderKey: testSenderKey, + recipient: aliceTestClient, + p2pSession: p2pSession, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // encrypt a message with the group session + messageEncrypted = encryptMegolmEvent({ + senderKey: testSenderKey, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // Alice gets both the events in a single sync + const syncResponse = { + next_batch: 1, + to_device: { + events: [roomKeyEncrypted], + }, + rooms: { + join: {}, + }, + }; + syncResponse.rooms.join[ROOM_ID] = { + timeline: { + events: [messageEncrypted], + }, + }; + + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); + return aliceTestClient.flushSync(); + }).then(function() { + const room = aliceTestClient.client.getRoom(ROOM_ID); + const event = room.getLiveTimeline().getEvents()[0]; + expect(event.getContent().body).toEqual('42'); + + return aliceTestClient.client.exportRoomKeys(); + }).then(function(exported) { + // start a new client + aliceTestClient.stop(); + + aliceTestClient = new TestClient( + "@alice:localhost", "device2", "access_token2", + ); + return aliceTestClient.client.initCrypto().then(() => { + aliceTestClient.client.importRoomKeys(exported); + return aliceTestClient.start(); + }); + }).then(function() { + const syncResponse = { + next_batch: 1, + rooms: { + join: {}, + }, + }; + syncResponse.rooms.join[ROOM_ID] = { + timeline: { + events: [messageEncrypted], + }, + }; + + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); + return aliceTestClient.flushSync(); + }).then(function() { + const room = aliceTestClient.client.getRoom(ROOM_ID); + const event = room.getLiveTimeline().getEvents()[0]; + expect(event.getContent().body).toEqual('42'); + }); + }); +}); diff --git a/matrix-js-sdk/spec/olm-loader.js b/matrix-js-sdk/spec/olm-loader.js new file mode 100644 index 000000000..c3353621b --- /dev/null +++ b/matrix-js-sdk/spec/olm-loader.js @@ -0,0 +1,25 @@ +/* +Copyright 2017 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. +*/ + +import logger from '../src/logger'; + +// try to load the olm library. +try { + global.Olm = require('olm'); + logger.log('loaded libolm'); +} catch (e) { + logger.warn("unable to run crypto tests: libolm not available"); +} diff --git a/matrix-js-sdk/spec/test-utils.js b/matrix-js-sdk/spec/test-utils.js new file mode 100644 index 000000000..d22ebc5df --- /dev/null +++ b/matrix-js-sdk/spec/test-utils.js @@ -0,0 +1,244 @@ +"use strict"; +import expect from 'expect'; +import Promise from 'bluebird'; + +// load olm before the sdk if possible +import './olm-loader'; + +import logger from '../src/logger'; +import sdk from '..'; +const MatrixEvent = sdk.MatrixEvent; + +/** + * Return a promise that is resolved when the client next emits a + * SYNCING event. + * @param {Object} client The client + * @param {Number=} count Number of syncs to wait for (default 1) + * @return {Promise} Resolves once the client has emitted a SYNCING event + */ +module.exports.syncPromise = function(client, count) { + if (count === undefined) { + count = 1; + } + if (count <= 0) { + return Promise.resolve(); + } + + const p = new Promise((resolve, reject) => { + const cb = (state) => { + logger.log(`${Date.now()} syncPromise(${count}): ${state}`); + if (state == 'SYNCING') { + resolve(); + } else { + client.once('sync', cb); + } + }; + client.once('sync', cb); + }); + + return p.then(() => { + return module.exports.syncPromise(client, count-1); + }); +}; + +/** + * Perform common actions before each test case, e.g. printing the test case + * name to stdout. + * @param {Mocha.Context} context The test context + */ +module.exports.beforeEach = function(context) { + const desc = context.currentTest.fullTitle(); + + logger.log(desc); + logger.log(new Array(1 + desc.length).join("=")); +}; + +/** + * Create a spy for an object and automatically spy its methods. + * @param {*} constr The class constructor (used with 'new') + * @param {string} name The name of the class + * @return {Object} An instantiated object with spied methods/properties. + */ +module.exports.mock = function(constr, name) { + // Based on + // http://eclipsesource.com/blogs/2014/03/27/mocks-in-jasmine-tests/ + const HelperConstr = new Function(); // jshint ignore:line + HelperConstr.prototype = constr.prototype; + const result = new HelperConstr(); + result.toString = function() { + return "mock" + (name ? " of " + name : ""); + }; + for (const key in constr.prototype) { // eslint-disable-line guard-for-in + try { + if (constr.prototype[key] instanceof Function) { + result[key] = expect.createSpy(); + } + } catch (ex) { + // Direct access to some non-function fields of DOM prototypes may + // cause exceptions. + // Overwriting will not work either in that case. + } + } + return result; +}; + +/** + * Create an Event. + * @param {Object} opts Values for the event. + * @param {string} opts.type The event.type + * @param {string} opts.room The event.room_id + * @param {string} opts.sender The event.sender + * @param {string} opts.skey Optional. The state key (auto inserts empty string) + * @param {Object} opts.content The event.content + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object} a JSON object representing this event. + */ +module.exports.mkEvent = function(opts) { + if (!opts.type || !opts.content) { + throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); + } + const event = { + type: opts.type, + room_id: opts.room, + sender: opts.sender || opts.user, // opts.user for backwards-compat + content: opts.content, + event_id: "$" + Math.random() + "-" + Math.random(), + }; + if (opts.skey !== undefined) { + event.state_key = opts.skey; + } else if (["m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules", + "m.room.power_levels", "m.room.topic", + "com.example.state"].indexOf(opts.type) !== -1) { + event.state_key = ""; + } + return opts.event ? new MatrixEvent(event) : event; +}; + +/** + * Create an m.presence event. + * @param {Object} opts Values for the presence. + * @return {Object|MatrixEvent} The event + */ +module.exports.mkPresence = function(opts) { + if (!opts.user) { + throw new Error("Missing user"); + } + const event = { + event_id: "$" + Math.random() + "-" + Math.random(), + type: "m.presence", + sender: opts.sender || opts.user, // opts.user for backwards-compat + content: { + avatar_url: opts.url, + displayname: opts.name, + last_active_ago: opts.ago, + presence: opts.presence || "offline", + }, + }; + return opts.event ? new MatrixEvent(event) : event; +}; + +/** + * Create an m.room.member event. + * @param {Object} opts Values for the membership. + * @param {string} opts.room The room ID for the event. + * @param {string} opts.mship The content.membership for the event. + * @param {string} opts.sender The sender user ID for the event. + * @param {string} opts.skey The target user ID for the event if applicable + * e.g. for invites/bans. + * @param {string} opts.name The content.displayname for the event. + * @param {string} opts.url The content.avatar_url for the event. + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object|MatrixEvent} The event + */ +module.exports.mkMembership = function(opts) { + opts.type = "m.room.member"; + if (!opts.skey) { + opts.skey = opts.sender || opts.user; + } + if (!opts.mship) { + throw new Error("Missing .mship => " + JSON.stringify(opts)); + } + opts.content = { + membership: opts.mship, + }; + if (opts.name) { + opts.content.displayname = opts.name; + } + if (opts.url) { + opts.content.avatar_url = opts.url; + } + return module.exports.mkEvent(opts); +}; + +/** + * Create an m.room.message event. + * @param {Object} opts Values for the message + * @param {string} opts.room The room ID for the event. + * @param {string} opts.user The user ID for the event. + * @param {string} opts.msg Optional. The content.body for the event. + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object|MatrixEvent} The event + */ +module.exports.mkMessage = function(opts) { + opts.type = "m.room.message"; + if (!opts.msg) { + opts.msg = "Random->" + Math.random(); + } + if (!opts.room || !opts.user) { + throw new Error("Missing .room or .user from %s", opts); + } + opts.content = { + msgtype: "m.text", + body: opts.msg, + }; + return module.exports.mkEvent(opts); +}; + + +/** + * A mock implementation of webstorage + * + * @constructor + */ +module.exports.MockStorageApi = function() { + this.data = {}; +}; +module.exports.MockStorageApi.prototype = { + get length() { + return Object.keys(this.data).length; + }, + key: function(i) { + return Object.keys(this.data)[i]; + }, + setItem: function(k, v) { + this.data[k] = v; + }, + getItem: function(k) { + return this.data[k] || null; + }, + removeItem: function(k) { + delete this.data[k]; + }, +}; + + +/** + * If an event is being decrypted, wait for it to finish being decrypted. + * + * @param {MatrixEvent} event + * @returns {Promise} promise which resolves (to `event`) when the event has been decrypted + */ +module.exports.awaitDecryption = function(event) { + if (!event.isBeingDecrypted()) { + return Promise.resolve(event); + } + + logger.log(`${Date.now()} event ${event.getId()} is being decrypted; waiting`); + + return new Promise((resolve, reject) => { + event.once('Event.decrypted', (ev) => { + logger.log(`${Date.now()} event ${event.getId()} now decrypted`); + resolve(ev); + }); + }); +}; diff --git a/matrix-js-sdk/spec/unit/autodiscovery.spec.js b/matrix-js-sdk/spec/unit/autodiscovery.spec.js new file mode 100644 index 000000000..6d4e5a3e9 --- /dev/null +++ b/matrix-js-sdk/spec/unit/autodiscovery.spec.js @@ -0,0 +1,670 @@ +/* +Copyright 2018 New Vector 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. +*/ +"use strict"; + +import 'source-map-support/register'; +import Promise from 'bluebird'; +const sdk = require("../.."); +const utils = require("../test-utils"); + +const AutoDiscovery = sdk.AutoDiscovery; + +import expect from 'expect'; +import MockHttpBackend from "matrix-mock-request"; + + +describe("AutoDiscovery", function() { + let httpBackend = null; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + httpBackend = new MockHttpBackend(); + sdk.request(httpBackend.requestFn); + }); + + it("should throw an error when no domain is specified", function() { + return Promise.all([ + AutoDiscovery.findClientConfig(/* no args */).then(() => { + throw new Error("Expected a failure, not success with no args"); + }, () => { + return true; + }), + + AutoDiscovery.findClientConfig("").then(() => { + throw new Error("Expected a failure, not success with an empty string"); + }, () => { + return true; + }), + + AutoDiscovery.findClientConfig(null).then(() => { + throw new Error("Expected a failure, not success with null"); + }, () => { + return true; + }), + + AutoDiscovery.findClientConfig(true).then(() => { + throw new Error("Expected a failure, not success with a non-string"); + }, () => { + return true; + }), + ]); + }); + + it("should return PROMPT when .well-known 404s", function() { + httpBackend.when("GET", "/.well-known/matrix/client").respond(404, {}); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "PROMPT", + error: null, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_PROMPT when .well-known returns a 500 error", function() { + httpBackend.when("GET", "/.well-known/matrix/client").respond(500, {}); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_PROMPT when .well-known returns a 400 error", function() { + httpBackend.when("GET", "/.well-known/matrix/client").respond(400, {}); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_PROMPT when .well-known returns an empty body", function() { + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, ""); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_PROMPT when .well-known returns not-JSON", function() { + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, "abc"); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_PROMPT when .well-known does not have a base_url for " + + "m.homeserver (empty string)", function() { + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + base_url: "", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID_HS_BASE_URL, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_PROMPT when .well-known does not have a base_url for " + + "m.homeserver (no property)", function() { + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": {}, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_PROMPT", + error: AutoDiscovery.ERROR_INVALID_HS_BASE_URL, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_ERROR when .well-known has an invalid base_url for " + + "m.homeserver (disallowed scheme)", function() { + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + base_url: "mxc://example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_HS_BASE_URL, + base_url: null, + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_ERROR when .well-known has an invalid base_url for " + + "m.homeserver (verification failure: 404)", function() { + httpBackend.when("GET", "/_matrix/client/versions").respond(404, {}); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + base_url: "https://example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_HOMESERVER, + base_url: "https://example.org", + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_ERROR when .well-known has an invalid base_url for " + + "m.homeserver (verification failure: 500)", function() { + httpBackend.when("GET", "/_matrix/client/versions").respond(500, {}); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + base_url: "https://example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_HOMESERVER, + base_url: "https://example.org", + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_ERROR when .well-known has an invalid base_url for " + + "m.homeserver (verification failure: 200 but wrong content)", function() { + httpBackend.when("GET", "/_matrix/client/versions").respond(200, { + not_matrix_versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + base_url: "https://example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_HOMESERVER, + base_url: "https://example.org", + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return SUCCESS when .well-known has a verifiably accurate base_url for " + + "m.homeserver", function() { + httpBackend.when("GET", "/_matrix/client/versions").check((req) => { + expect(req.opts.uri).toEqual("https://example.org/_matrix/client/versions"); + }).respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + base_url: "https://example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "SUCCESS", + error: null, + base_url: "https://example.org", + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return SUCCESS with the right homeserver URL", function() { + httpBackend.when("GET", "/_matrix/client/versions").check((req) => { + expect(req.opts.uri) + .toEqual("https://chat.example.org/_matrix/client/versions"); + }).respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "SUCCESS", + error: null, + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "PROMPT", + error: null, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_ERROR when the identity server configuration is wrong " + + "(missing base_url)", function() { + httpBackend.when("GET", "/_matrix/client/versions").check((req) => { + expect(req.opts.uri) + .toEqual("https://chat.example.org/_matrix/client/versions"); + }).respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.identity_server": { + not_base_url: "https://identity.example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_IS, + + // We still expect the base_url to be here for debugging purposes. + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_IS_BASE_URL, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_ERROR when the identity server configuration is wrong " + + "(empty base_url)", function() { + httpBackend.when("GET", "/_matrix/client/versions").check((req) => { + expect(req.opts.uri) + .toEqual("https://chat.example.org/_matrix/client/versions"); + }).respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.identity_server": { + base_url: "", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_IS, + + // We still expect the base_url to be here for debugging purposes. + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_IS_BASE_URL, + base_url: null, + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_ERROR when the identity server configuration is wrong " + + "(validation error: 404)", function() { + httpBackend.when("GET", "/_matrix/client/versions").check((req) => { + expect(req.opts.uri) + .toEqual("https://chat.example.org/_matrix/client/versions"); + }).respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/_matrix/identity/api/v1").respond(404, {}); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.identity_server": { + base_url: "https://identity.example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_IS, + + // We still expect the base_url to be here for debugging purposes. + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, + base_url: "https://identity.example.org", + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return FAIL_ERROR when the identity server configuration is wrong " + + "(validation error: 500)", function() { + httpBackend.when("GET", "/_matrix/client/versions").check((req) => { + expect(req.opts.uri) + .toEqual("https://chat.example.org/_matrix/client/versions"); + }).respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/_matrix/identity/api/v1").respond(500, {}); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.identity_server": { + base_url: "https://identity.example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_IS, + + // We still expect the base_url to be here for debugging purposes + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "FAIL_ERROR", + error: AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, + base_url: "https://identity.example.org", + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return SUCCESS when the identity server configuration is " + + "verifiably accurate", function() { + httpBackend.when("GET", "/_matrix/client/versions").check((req) => { + expect(req.opts.uri) + .toEqual("https://chat.example.org/_matrix/client/versions"); + }).respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/_matrix/identity/api/v1").check((req) => { + expect(req.opts.uri) + .toEqual("https://identity.example.org/_matrix/identity/api/v1"); + }).respond(200, {}); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.identity_server": { + base_url: "https://identity.example.org", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "SUCCESS", + error: null, + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "SUCCESS", + error: null, + base_url: "https://identity.example.org", + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); + + it("should return SUCCESS and preserve non-standard keys from the " + + ".well-known response", function() { + httpBackend.when("GET", "/_matrix/client/versions").check((req) => { + expect(req.opts.uri) + .toEqual("https://chat.example.org/_matrix/client/versions"); + }).respond(200, { + versions: ["r0.0.1"], + }); + httpBackend.when("GET", "/_matrix/identity/api/v1").check((req) => { + expect(req.opts.uri) + .toEqual("https://identity.example.org/_matrix/identity/api/v1"); + }).respond(200, {}); + httpBackend.when("GET", "/.well-known/matrix/client").respond(200, { + "m.homeserver": { + // Note: we also expect this test to trim the trailing slash + base_url: "https://chat.example.org/", + }, + "m.identity_server": { + base_url: "https://identity.example.org", + }, + "org.example.custom.property": { + cupcakes: "yes", + }, + }); + return Promise.all([ + httpBackend.flushAllExpected(), + AutoDiscovery.findClientConfig("example.org").then((conf) => { + const expected = { + "m.homeserver": { + state: "SUCCESS", + error: null, + base_url: "https://chat.example.org", + }, + "m.identity_server": { + state: "SUCCESS", + error: null, + base_url: "https://identity.example.org", + }, + "org.example.custom.property": { + cupcakes: "yes", + }, + }; + + expect(conf).toEqual(expected); + }), + ]); + }); +}); diff --git a/matrix-js-sdk/spec/unit/content-repo.spec.js b/matrix-js-sdk/spec/unit/content-repo.spec.js new file mode 100644 index 000000000..61b6fceb1 --- /dev/null +++ b/matrix-js-sdk/spec/unit/content-repo.spec.js @@ -0,0 +1,95 @@ +"use strict"; +import 'source-map-support/register'; +const ContentRepo = require("../../lib/content-repo"); +const testUtils = require("../test-utils"); + +import expect from 'expect'; + +describe("ContentRepo", function() { + const baseUrl = "https://my.home.server"; + + beforeEach(function() { + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + }); + + describe("getHttpUriForMxc", function() { + it("should do nothing to HTTP URLs when allowing direct links", function() { + const httpUrl = "http://example.com/image.jpeg"; + expect( + ContentRepo.getHttpUriForMxc( + baseUrl, httpUrl, undefined, undefined, undefined, true, + ), + ).toEqual(httpUrl); + }); + + it("should return the empty string HTTP URLs by default", function() { + const httpUrl = "http://example.com/image.jpeg"; + expect(ContentRepo.getHttpUriForMxc(baseUrl, httpUrl)).toEqual(""); + }); + + it("should return a download URL if no width/height/resize are specified", + function() { + const mxcUri = "mxc://server.name/resourceid"; + expect(ContentRepo.getHttpUriForMxc(baseUrl, mxcUri)).toEqual( + baseUrl + "/_matrix/media/r0/download/server.name/resourceid", + ); + }); + + it("should return the empty string for null input", function() { + expect(ContentRepo.getHttpUriForMxc(null)).toEqual(""); + }); + + it("should return a thumbnail URL if a width/height/resize is specified", + function() { + const mxcUri = "mxc://server.name/resourceid"; + expect(ContentRepo.getHttpUriForMxc(baseUrl, mxcUri, 32, 64, "crop")).toEqual( + baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" + + "?width=32&height=64&method=crop", + ); + }); + + it("should put fragments from mxc:// URIs after any query parameters", + function() { + const mxcUri = "mxc://server.name/resourceid#automade"; + expect(ContentRepo.getHttpUriForMxc(baseUrl, mxcUri, 32)).toEqual( + baseUrl + "/_matrix/media/r0/thumbnail/server.name/resourceid" + + "?width=32#automade", + ); + }); + + it("should put fragments from mxc:// URIs at the end of the HTTP URI", + function() { + const mxcUri = "mxc://server.name/resourceid#automade"; + expect(ContentRepo.getHttpUriForMxc(baseUrl, mxcUri)).toEqual( + baseUrl + "/_matrix/media/r0/download/server.name/resourceid#automade", + ); + }); + }); + + describe("getIdenticonUri", function() { + it("should do nothing for null input", function() { + expect(ContentRepo.getIdenticonUri(null)).toEqual(null); + }); + + it("should set w/h by default to 96", function() { + expect(ContentRepo.getIdenticonUri(baseUrl, "foobar")).toEqual( + baseUrl + "/_matrix/media/unstable/identicon/foobar" + + "?width=96&height=96", + ); + }); + + it("should be able to set custom w/h", function() { + expect(ContentRepo.getIdenticonUri(baseUrl, "foobar", 32, 64)).toEqual( + baseUrl + "/_matrix/media/unstable/identicon/foobar" + + "?width=32&height=64", + ); + }); + + it("should URL encode the identicon string", function() { + expect(ContentRepo.getIdenticonUri(baseUrl, "foo#bar", 32, 64)).toEqual( + baseUrl + "/_matrix/media/unstable/identicon/foo%23bar" + + "?width=32&height=64", + ); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/crypto.spec.js b/matrix-js-sdk/spec/unit/crypto.spec.js new file mode 100644 index 000000000..1c25e6f68 --- /dev/null +++ b/matrix-js-sdk/spec/unit/crypto.spec.js @@ -0,0 +1,366 @@ +import 'source-map-support/register'; + +import '../olm-loader'; + +import Crypto from '../../lib/crypto'; +import expect from 'expect'; + +import WebStorageSessionStore from '../../lib/store/session/webstorage'; +import MemoryCryptoStore from '../../lib/crypto/store/memory-crypto-store.js'; +import MockStorageApi from '../MockStorageApi'; +import TestClient from '../TestClient'; +import {MatrixEvent} from '../../lib/models/event'; +import Room from '../../lib/models/room'; +import olmlib from '../../lib/crypto/olmlib'; +import lolex from 'lolex'; + +const EventEmitter = require("events").EventEmitter; + +const sdk = require("../.."); + +const Olm = global.Olm; + +describe("Crypto", function() { + if (!sdk.CRYPTO_ENABLED) { + return; + } + + beforeEach(function(done) { + Olm.init().then(done); + }); + + it("Crypto exposes the correct olm library version", function() { + expect(Crypto.getOlmVersion()[0]).toEqual(3); + }); + + + describe('Session management', function() { + const otkResponse = { + one_time_keys: { + '@alice:home.server': { + aliceDevice: { + 'signed_curve25519:FLIBBLE': { + key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI', + signatures: { + '@alice:home.server': { + 'ed25519:aliceDevice': 'totally a valid signature', + }, + }, + }, + }, + }, + }, + }; + let crypto; + let mockBaseApis; + let mockRoomList; + + let fakeEmitter; + + beforeEach(async function() { + const mockStorage = new MockStorageApi(); + const sessionStore = new WebStorageSessionStore(mockStorage); + const cryptoStore = new MemoryCryptoStore(mockStorage); + + cryptoStore.storeEndToEndDeviceData({ + devices: { + '@bob:home.server': { + 'BOBDEVICE': { + keys: { + 'curve25519:BOBDEVICE': 'this is a key', + }, + }, + }, + }, + trackingStatus: {}, + }); + + mockBaseApis = { + sendToDevice: expect.createSpy(), + getKeyBackupVersion: expect.createSpy(), + isGuest: expect.createSpy(), + }; + mockRoomList = {}; + + fakeEmitter = new EventEmitter(); + + crypto = new Crypto( + mockBaseApis, + sessionStore, + "@alice:home.server", + "FLIBBLE", + sessionStore, + cryptoStore, + mockRoomList, + ); + crypto.registerEventHandlers(fakeEmitter); + await crypto.init(); + }); + + afterEach(async function() { + await crypto.stop(); + }); + + it("restarts wedged Olm sessions", async function() { + const prom = new Promise((resolve) => { + mockBaseApis.claimOneTimeKeys = function() { + resolve(); + return otkResponse; + }; + }); + + fakeEmitter.emit('toDeviceEvent', { + getType: expect.createSpy().andReturn('m.room.message'), + getContent: expect.createSpy().andReturn({ + msgtype: 'm.bad.encrypted', + }), + getWireContent: expect.createSpy().andReturn({ + algorithm: 'm.olm.v1.curve25519-aes-sha2', + sender_key: 'this is a key', + }), + getSender: expect.createSpy().andReturn('@bob:home.server'), + }); + + await prom; + }); + }); + + describe('Key requests', function() { + let aliceClient; + let bobClient; + + beforeEach(async function() { + aliceClient = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + bobClient = (new TestClient( + "@bob:example.com", "bobdevice", + )).client; + await aliceClient.initCrypto(); + await bobClient.initCrypto(); + }); + + afterEach(async function() { + aliceClient.stopClient(); + bobClient.stopClient(); + }); + + it( + "does not cancel keyshare requests if some messages are not decrypted", + async function() { + function awaitEvent(emitter, event) { + return new Promise((resolve, reject) => { + emitter.once(event, (result) => { + resolve(result); + }); + }); + } + + async function keyshareEventForEvent(event, index) { + const eventContent = event.getWireContent(); + const key = await aliceClient._crypto._olmDevice + .getInboundGroupSessionKey( + roomId, eventContent.sender_key, eventContent.session_id, + index, + ); + const ksEvent = new MatrixEvent({ + type: "m.forwarded_room_key", + sender: "@alice:example.com", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: roomId, + sender_key: eventContent.sender_key, + sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, + session_id: eventContent.session_id, + session_key: key.key, + chain_index: key.chain_index, + forwarding_curve25519_key_chain: + key.forwarding_curve_key_chain, + }, + }); + // make onRoomKeyEvent think this was an encrypted event + ksEvent._senderCurve25519Key = "akey"; + return ksEvent; + } + + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + aliceClient.store.storeRoom(aliceRoom); + bobClient.store.storeRoom(bobRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + await bobClient.setRoomEncryption(roomId, encryptionCfg); + const events = [ + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$1", + content: { + msgtype: "m.text", + body: "1", + }, + }), + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$2", + content: { + msgtype: "m.text", + body: "2", + }, + }), + ]; + await Promise.all(events.map(async (event) => { + // alice encrypts each event, and then bob tries to decrypt + // them without any keys, so that they'll be in pending + await aliceClient._crypto.encryptEvent(event, aliceRoom); + event._clearEvent = {}; + event._senderCurve25519Key = null; + event._claimedEd25519Key = null; + try { + await bobClient._crypto.decryptEvent(event); + } catch (e) { + // we expect this to fail because we don't have the + // decryption keys yet + } + })); + + const bobDecryptor = bobClient._crypto._getRoomDecryptor( + roomId, olmlib.MEGOLM_ALGORITHM, + ); + + let eventPromise = Promise.all(events.map((ev) => { + return awaitEvent(ev, "Event.decrypted"); + })); + + // keyshare the session key starting at the second message, so + // the first message can't be decrypted yet, but the second one + // can + let ksEvent = await keyshareEventForEvent(events[1], 1); + await bobDecryptor.onRoomKeyEvent(ksEvent); + await eventPromise; + expect(events[0].getContent().msgtype).toBe("m.bad.encrypted"); + expect(events[1].getContent().msgtype).toNotBe("m.bad.encrypted"); + + const cryptoStore = bobClient._cryptoStore; + const eventContent = events[0].getWireContent(); + const senderKey = eventContent.sender_key; + const sessionId = eventContent.session_id; + const roomKeyRequestBody = { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: roomId, + sender_key: senderKey, + session_id: sessionId, + }; + // the room key request should still be there, since we haven't + // decrypted everything + expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) + .toExist(); + + // keyshare the session key starting at the first message, so + // that it can now be decrypted + eventPromise = awaitEvent(events[0], "Event.decrypted"); + ksEvent = await keyshareEventForEvent(events[0], 0); + await bobDecryptor.onRoomKeyEvent(ksEvent); + await eventPromise; + expect(events[0].getContent().msgtype).toNotBe("m.bad.encrypted"); + // the room key request should be gone since we've now decypted everything + expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) + .toNotExist(); + }, + ); + + it("creates a new keyshare request if we request a keyshare", async function() { + // make sure that cancelAndResend... creates a new keyshare request + // if there wasn't an already-existing one + const event = new MatrixEvent({ + sender: "@bob:example.com", + room_id: "!someroom", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + session_id: "sessionid", + sender_key: "senderkey", + }, + }); + await aliceClient.cancelAndResendEventRoomKeyRequest(event); + const cryptoStore = aliceClient._cryptoStore; + const roomKeyRequestBody = { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: "!someroom", + session_id: "sessionid", + sender_key: "senderkey", + }; + expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) + .toExist(); + }); + + it("uses a new txnid for re-requesting keys", async function() { + const event = new MatrixEvent({ + sender: "@bob:example.com", + room_id: "!someroom", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + session_id: "sessionid", + sender_key: "senderkey", + }, + }); + /* return a promise and a function. When the function is called, + * the promise will be resolved. + */ + function awaitFunctionCall() { + let func; + const promise = new Promise((resolve, reject) => { + func = function(...args) { + resolve(args); + return new Promise((resolve, reject) => { + // give us some time to process the result before + // continuing + global.setTimeout(resolve, 1); + }); + }; + }); + return {func, promise}; + } + + aliceClient.startClient(); + + const clock = lolex.install(); + + try { + let promise; + // make a room key request, and record the transaction ID for the + // sendToDevice call + ({promise, func: aliceClient.sendToDevice} = awaitFunctionCall()); + await aliceClient.cancelAndResendEventRoomKeyRequest(event); + clock.runToLast(); + let args = await promise; + const txnId = args[2]; + clock.runToLast(); + + // give the room key request manager time to update the state + // of the request + await Promise.resolve(); + + // cancel and resend the room key request + ({promise, func: aliceClient.sendToDevice} = awaitFunctionCall()); + await aliceClient.cancelAndResendEventRoomKeyRequest(event); + clock.runToLast(); + // the first call to sendToDevice will be the cancellation + args = await promise; + // the second call to sendToDevice will be the key request + ({promise, func: aliceClient.sendToDevice} = awaitFunctionCall()); + clock.runToLast(); + args = await promise; + clock.runToLast(); + expect(args[2]).toNotBe(txnId); + } finally { + clock.uninstall(); + } + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/crypto/DeviceList.spec.js b/matrix-js-sdk/spec/unit/crypto/DeviceList.spec.js new file mode 100644 index 000000000..33c1e4955 --- /dev/null +++ b/matrix-js-sdk/spec/unit/crypto/DeviceList.spec.js @@ -0,0 +1,156 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018, 2019 New Vector 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. +*/ + +import DeviceList from '../../../lib/crypto/DeviceList'; +import MemoryCryptoStore from '../../../lib/crypto/store/memory-crypto-store.js'; +import testUtils from '../../test-utils'; +import utils from '../../../lib/utils'; +import logger from '../../../src/logger'; + +import expect from 'expect'; +import Promise from 'bluebird'; + +const signedDeviceList = { + "failures": {}, + "device_keys": { + "@test1:sw1v.org": { + "HGKAWHRVJQ": { + "signatures": { + "@test1:sw1v.org": { + "ed25519:HGKAWHRVJQ": + "8PB450fxKDn5s8IiRZ2N2t6MiueQYVRLHFEzqIi1eLdxx1w" + + "XEPC1/1Uz9T4gwnKlMVAKkhB5hXQA/3kjaeLABw", + }, + }, + "user_id": "@test1:sw1v.org", + "keys": { + "ed25519:HGKAWHRVJQ": + "0gI/T6C+mn1pjtvnnW2yB2l1IIBb/5ULlBXi/LXFSEQ", + "curve25519:HGKAWHRVJQ": + "mbIZED1dBsgIgkgzxDpxKkJmsr4hiWlGzQTvUnQe3RY", + }, + "algorithms": [ + "m.olm.v1.curve25519-aes-sha2", + "m.megolm.v1.aes-sha2", + ], + "device_id": "HGKAWHRVJQ", + "unsigned": {}, + }, + }, + }, +}; + +describe('DeviceList', function() { + let downloadSpy; + let cryptoStore; + let deviceLists = []; + + beforeEach(function() { + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + + deviceLists = []; + + downloadSpy = expect.createSpy(); + cryptoStore = new MemoryCryptoStore(); + }); + + afterEach(function() { + for (const dl of deviceLists) { + dl.stop(); + } + }); + + function createTestDeviceList() { + const baseApis = { + downloadKeysForUsers: downloadSpy, + }; + const mockOlm = { + verifySignature: function(key, message, signature) {}, + }; + const dl = new DeviceList(baseApis, cryptoStore, mockOlm); + deviceLists.push(dl); + return dl; + } + + it("should successfully download and store device keys", function() { + const dl = createTestDeviceList(); + + dl.startTrackingDeviceList('@test1:sw1v.org'); + + const queryDefer1 = Promise.defer(); + downloadSpy.andReturn(queryDefer1.promise); + + const prom1 = dl.refreshOutdatedDeviceLists(); + expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {}); + queryDefer1.resolve(utils.deepCopy(signedDeviceList)); + + return prom1.then(() => { + const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); + expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); + }); + }); + + it("should have an outdated devicelist on an invalidation while an " + + "update is in progress", function() { + const dl = createTestDeviceList(); + + dl.startTrackingDeviceList('@test1:sw1v.org'); + + const queryDefer1 = Promise.defer(); + downloadSpy.andReturn(queryDefer1.promise); + + const prom1 = dl.refreshOutdatedDeviceLists(); + expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {}); + downloadSpy.reset(); + + // outdated notif arrives while the request is in flight. + const queryDefer2 = Promise.defer(); + downloadSpy.andReturn(queryDefer2.promise); + + dl.invalidateUserDeviceList('@test1:sw1v.org'); + dl.refreshOutdatedDeviceLists(); + + dl.saveIfDirty().then(() => { + // the first request completes + queryDefer1.resolve({ + device_keys: { + '@test1:sw1v.org': {}, + }, + }); + return prom1; + }).then(() => { + // uh-oh; user restarts before second request completes. The new instance + // should know we never got a complete device list. + logger.log("Creating new devicelist to simulate app reload"); + downloadSpy.reset(); + const dl2 = createTestDeviceList(); + const queryDefer3 = Promise.defer(); + downloadSpy.andReturn(queryDefer3.promise); + + const prom3 = dl2.refreshOutdatedDeviceLists(); + expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {}); + + queryDefer3.resolve(utils.deepCopy(signedDeviceList)); + + // allow promise chain to complete + return prom3; + }).then(() => { + const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); + expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/crypto/algorithms/megolm.spec.js b/matrix-js-sdk/spec/unit/crypto/algorithms/megolm.spec.js new file mode 100644 index 000000000..eee045c15 --- /dev/null +++ b/matrix-js-sdk/spec/unit/crypto/algorithms/megolm.spec.js @@ -0,0 +1,350 @@ +import '../../../olm-loader'; + +import expect from 'expect'; +import Promise from 'bluebird'; + +import sdk from '../../../..'; +import algorithms from '../../../../lib/crypto/algorithms'; +import MemoryCryptoStore from '../../../../lib/crypto/store/memory-crypto-store.js'; +import MockStorageApi from '../../../MockStorageApi'; +import testUtils from '../../../test-utils'; +import OlmDevice from '../../../../lib/crypto/OlmDevice'; +import Crypto from '../../../../lib/crypto'; +import logger from '../../../../src/logger'; + +const MatrixEvent = sdk.MatrixEvent; +const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get('m.megolm.v1.aes-sha2'); +const MegolmEncryption = algorithms.ENCRYPTION_CLASSES.get('m.megolm.v1.aes-sha2'); + +const ROOM_ID = '!ROOM:ID'; + +const Olm = global.Olm; + +describe("MegolmDecryption", function() { + if (!global.Olm) { + logger.warn('Not running megolm unit tests: libolm not present'); + return; + } + + let megolmDecryption; + let mockOlmLib; + let mockCrypto; + let mockBaseApis; + + beforeEach(async function() { + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + + await Olm.init(); + + mockCrypto = testUtils.mock(Crypto, 'Crypto'); + mockBaseApis = {}; + + const mockStorage = new MockStorageApi(); + const cryptoStore = new MemoryCryptoStore(mockStorage); + + const olmDevice = new OlmDevice(cryptoStore); + + megolmDecryption = new MegolmDecryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: mockBaseApis, + roomId: ROOM_ID, + }); + + + // we stub out the olm encryption bits + mockOlmLib = {}; + mockOlmLib.ensureOlmSessionsForDevices = expect.createSpy(); + mockOlmLib.encryptMessageForDevice = + expect.createSpy().andReturn(Promise.resolve()); + megolmDecryption.olmlib = mockOlmLib; + }); + + describe('receives some keys:', function() { + let groupSession; + beforeEach(async function() { + groupSession = new global.Olm.OutboundGroupSession(); + groupSession.create(); + + // construct a fake decrypted key event via the use of a mocked + // 'crypto' implementation. + const event = new MatrixEvent({ + type: 'm.room.encrypted', + }); + const decryptedData = { + clearEvent: { + type: 'm.room_key', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + room_id: ROOM_ID, + session_id: groupSession.session_id(), + session_key: groupSession.session_key(), + }, + }, + senderCurve25519Key: "SENDER_CURVE25519", + claimedEd25519Key: "SENDER_ED25519", + }; + + const mockCrypto = { + decryptEvent: function() { + return Promise.resolve(decryptedData); + }, + }; + + await event.attemptDecryption(mockCrypto).then(() => { + megolmDecryption.onRoomKeyEvent(event); + }); + }); + + it('can decrypt an event', function() { + const event = new MatrixEvent({ + type: 'm.room.encrypted', + room_id: ROOM_ID, + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: "SENDER_CURVE25519", + session_id: groupSession.session_id(), + ciphertext: groupSession.encrypt(JSON.stringify({ + room_id: ROOM_ID, + content: 'testytest', + })), + }, + }); + + return megolmDecryption.decryptEvent(event).then((res) => { + expect(res.clearEvent.content).toEqual('testytest'); + }); + }); + + it('can respond to a key request event', function() { + const keyRequest = { + userId: '@alice:foo', + deviceId: 'alidevice', + requestBody: { + room_id: ROOM_ID, + sender_key: "SENDER_CURVE25519", + session_id: groupSession.session_id(), + }, + }; + + return megolmDecryption.hasKeysForKeyRequest( + keyRequest, + ).then((hasKeys) => { + expect(hasKeys).toBe(true); + + // set up some pre-conditions for the share call + const deviceInfo = {}; + mockCrypto.getStoredDevice.andReturn(deviceInfo); + + mockOlmLib.ensureOlmSessionsForDevices.andReturn( + Promise.resolve({'@alice:foo': {'alidevice': { + sessionId: 'alisession', + }}}), + ); + + const awaitEncryptForDevice = new Promise((res, rej) => { + mockOlmLib.encryptMessageForDevice.andCall(() => { + res(); + return Promise.resolve(); + }); + }); + + mockBaseApis.sendToDevice = expect.createSpy(); + + // do the share + megolmDecryption.shareKeysWithDevice(keyRequest); + + // it's asynchronous, so we have to wait a bit + return awaitEncryptForDevice; + }).then(() => { + // check that it called encryptMessageForDevice with + // appropriate args. + expect(mockOlmLib.encryptMessageForDevice.calls.length) + .toEqual(1); + + const call = mockOlmLib.encryptMessageForDevice.calls[0]; + const payload = call.arguments[6]; + + expect(payload.type).toEqual("m.forwarded_room_key"); + expect(payload.content).toInclude({ + sender_key: "SENDER_CURVE25519", + sender_claimed_ed25519_key: "SENDER_ED25519", + session_id: groupSession.session_id(), + chain_index: 0, + forwarding_curve25519_key_chain: [], + }); + expect(payload.content.session_key).toExist(); + }); + }); + + it("can detect replay attacks", function() { + // trying to decrypt two different messages (marked by different + // event IDs or timestamps) using the same (sender key, session id, + // message index) triple should result in an exception being thrown + // as it should be detected as a replay attack. + const sessionId = groupSession.session_id(); + const cipherText = groupSession.encrypt(JSON.stringify({ + room_id: ROOM_ID, + content: 'testytest', + })); + const event1 = new MatrixEvent({ + type: 'm.room.encrypted', + room_id: ROOM_ID, + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: "SENDER_CURVE25519", + session_id: sessionId, + ciphertext: cipherText, + }, + event_id: "$event1", + origin_server_ts: 1507753886000, + }); + + const successHandler = expect.createSpy(); + const failureHandler = expect.createSpy() + .andCall((err) => { + expect(err.toString()).toMatch( + /Duplicate message index, possible replay attack/, + ); + }); + + return megolmDecryption.decryptEvent(event1).then((res) => { + const event2 = new MatrixEvent({ + type: 'm.room.encrypted', + room_id: ROOM_ID, + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: "SENDER_CURVE25519", + session_id: sessionId, + ciphertext: cipherText, + }, + event_id: "$event2", + origin_server_ts: 1507754149000, + }); + + return megolmDecryption.decryptEvent(event2); + }).then( + successHandler, + failureHandler, + ).then(() => { + expect(successHandler).toNotHaveBeenCalled(); + expect(failureHandler).toHaveBeenCalled(); + }); + }); + + it("allows re-decryption of the same event", function() { + // in contrast with the previous test, if the event ID and + // timestamp are the same, then it should not be considered a + // replay attack + const sessionId = groupSession.session_id(); + const cipherText = groupSession.encrypt(JSON.stringify({ + room_id: ROOM_ID, + content: 'testytest', + })); + const event = new MatrixEvent({ + type: 'm.room.encrypted', + room_id: ROOM_ID, + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: "SENDER_CURVE25519", + session_id: sessionId, + ciphertext: cipherText, + }, + event_id: "$event1", + origin_server_ts: 1507753886000, + }); + + return megolmDecryption.decryptEvent(event).then((res) => { + return megolmDecryption.decryptEvent(event); + // test is successful if no exception is thrown + }); + }); + + it("re-uses sessions for sequential messages", async function() { + const mockStorage = new MockStorageApi(); + const cryptoStore = new MemoryCryptoStore(mockStorage); + + const olmDevice = new OlmDevice(cryptoStore); + olmDevice.verifySignature = expect.createSpy(); + await olmDevice.init(); + + mockBaseApis.claimOneTimeKeys = expect.createSpy().andReturn(Promise.resolve({ + one_time_keys: { + '@alice:home.server': { + aliceDevice: { + 'signed_curve25519:flooble': { + key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI', + signatures: { + '@alice:home.server': { + 'ed25519:aliceDevice': 'totally valid', + }, + }, + }, + }, + }, + }, + })); + mockBaseApis.sendToDevice = expect.createSpy().andReturn(Promise.resolve()); + + mockCrypto.downloadKeys.andReturn(Promise.resolve({ + '@alice:home.server': { + aliceDevice: { + deviceId: 'aliceDevice', + isBlocked: expect.createSpy().andReturn(false), + isUnverified: expect.createSpy().andReturn(false), + getIdentityKey: expect.createSpy().andReturn( + 'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE', + ), + getFingerprint: expect.createSpy().andReturn(''), + }, + }, + })); + + const megolmEncryption = new MegolmEncryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: mockBaseApis, + roomId: ROOM_ID, + config: { + rotation_period_ms: 9999999999999, + }, + }); + const mockRoom = { + getEncryptionTargetMembers: expect.createSpy().andReturn( + [{userId: "@alice:home.server"}], + ), + getBlacklistUnverifiedDevices: expect.createSpy().andReturn(false), + }; + const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { + body: "Some text", + }); + expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled(); + + // this should have claimed a key for alice as it's starting a new session + expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalled( + [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', + ); + expect(mockCrypto.downloadKeys).toHaveBeenCalledWith( + ['@alice:home.server'], false, + ); + expect(mockBaseApis.sendToDevice).toHaveBeenCalled(); + expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalled( + [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', + ); + + mockBaseApis.claimOneTimeKeys.reset(); + + const ct2 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { + body: "Some more text", + }); + + // this should *not* have claimed a key as it should be using the same session + expect(mockBaseApis.claimOneTimeKeys).toNotHaveBeenCalled(); + + // likewise they should show the same session ID + expect(ct2.session_id).toEqual(ct1.session_id); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/crypto/algorithms/olm.spec.js b/matrix-js-sdk/spec/unit/crypto/algorithms/olm.spec.js new file mode 100644 index 000000000..14e6bbf66 --- /dev/null +++ b/matrix-js-sdk/spec/unit/crypto/algorithms/olm.spec.js @@ -0,0 +1,143 @@ +/* +Copyright 2018,2019 New Vector 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. +*/ + +import '../../../olm-loader'; + +import expect from 'expect'; +import MemoryCryptoStore from '../../../../lib/crypto/store/memory-crypto-store.js'; +import MockStorageApi from '../../../MockStorageApi'; +import testUtils from '../../../test-utils'; +import logger from '../../../../src/logger'; + +import OlmDevice from '../../../../lib/crypto/OlmDevice'; +import olmlib from '../../../../lib/crypto/olmlib'; +import DeviceInfo from '../../../../lib/crypto/deviceinfo'; + +function makeOlmDevice() { + const mockStorage = new MockStorageApi(); + const cryptoStore = new MemoryCryptoStore(mockStorage); + const olmDevice = new OlmDevice(cryptoStore); + return olmDevice; +} + +async function setupSession(initiator, opponent) { + await opponent.generateOneTimeKeys(1); + const keys = await opponent.getOneTimeKeys(); + const firstKey = Object.values(keys['curve25519'])[0]; + + const sid = await initiator.createOutboundSession( + opponent.deviceCurve25519Key, firstKey, + ); + return sid; +} + +describe("OlmDecryption", function() { + if (!global.Olm) { + logger.warn('Not running megolm unit tests: libolm not present'); + return; + } + + let aliceOlmDevice; + let bobOlmDevice; + + beforeEach(async function() { + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + + await global.Olm.init(); + + aliceOlmDevice = makeOlmDevice(); + bobOlmDevice = makeOlmDevice(); + await aliceOlmDevice.init(); + await bobOlmDevice.init(); + }); + + describe('olm', function() { + it("can decrypt messages", async function() { + const sid = await setupSession(aliceOlmDevice, bobOlmDevice); + + const ciphertext = await aliceOlmDevice.encryptMessage( + bobOlmDevice.deviceCurve25519Key, + sid, + "The olm or proteus is an aquatic salamander in the family Proteidae", + ); + + const result = await bobOlmDevice.createInboundSession( + aliceOlmDevice.deviceCurve25519Key, + ciphertext.type, + ciphertext.body, + ); + expect(result.payload).toEqual( + "The olm or proteus is an aquatic salamander in the family Proteidae", + ); + }); + + it("creates only one session at a time", async function() { + // if we call ensureOlmSessionsForDevices multiple times, it should + // only try to create one session at a time, even if the server is + // slow + let count = 0; + const baseApis = { + claimOneTimeKeys: () => { + // simulate a very slow server (.5 seconds to respond) + count++; + return new Promise((resolve, reject) => { + setTimeout(reject, 500); + }); + }, + }; + const devicesByUser = { + "@bob:example.com": [ + DeviceInfo.fromStorage({ + keys: { + "curve25519:ABCDEFG": "akey", + }, + }, "ABCDEFG"), + ], + }; + function alwaysSucceed(promise) { + // swallow any exception thrown by a promise, so that + // Promise.all doesn't abort + return promise.catch(() => {}); + } + + // start two tasks that try to ensure that there's an olm session + const promises = Promise.all([ + alwaysSucceed(olmlib.ensureOlmSessionsForDevices( + aliceOlmDevice, baseApis, devicesByUser, + )), + alwaysSucceed(olmlib.ensureOlmSessionsForDevices( + aliceOlmDevice, baseApis, devicesByUser, + )), + ]); + + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + // after .2s, both tasks should have started, but one should be + // waiting on the other before trying to create a session, so + // claimOneTimeKeys should have only been called once + expect(count).toBe(1); + + await promises; + + // after waiting for both tasks to complete, the first task should + // have failed, so the second task should have tried to create a + // new session and will have called claimOneTimeKeys + expect(count).toBe(2); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/crypto/backup.spec.js b/matrix-js-sdk/spec/unit/crypto/backup.spec.js new file mode 100644 index 000000000..d296f5856 --- /dev/null +++ b/matrix-js-sdk/spec/unit/crypto/backup.spec.js @@ -0,0 +1,472 @@ +/* +Copyright 2018 New Vector 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. +*/ + +import '../../olm-loader'; + +import expect from 'expect'; +import Promise from 'bluebird'; + +import sdk from '../../..'; +import algorithms from '../../../lib/crypto/algorithms'; +import WebStorageSessionStore from '../../../lib/store/session/webstorage'; +import MemoryCryptoStore from '../../../lib/crypto/store/memory-crypto-store.js'; +import MockStorageApi from '../../MockStorageApi'; +import testUtils from '../../test-utils'; + +import OlmDevice from '../../../lib/crypto/OlmDevice'; +import Crypto from '../../../lib/crypto'; +import logger from '../../../src/logger'; + +const Olm = global.Olm; + +const MatrixClient = sdk.MatrixClient; +const MatrixEvent = sdk.MatrixEvent; +const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get('m.megolm.v1.aes-sha2'); + +const ROOM_ID = '!ROOM:ID'; + +const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc'; +const ENCRYPTED_EVENT = new MatrixEvent({ + type: 'm.room.encrypted', + room_id: '!ROOM:ID', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: 'SENDER_CURVE25519', + session_id: SESSION_ID, + ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N' + + 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl' + + 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs', + }, + event_id: '$event1', + origin_server_ts: 1507753886000, +}); + +const KEY_BACKUP_DATA = { + first_message_index: 0, + forwarded_count: 0, + is_verified: false, + session_data: { + ciphertext: '2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw' + + '6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ' + + 'Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9' + + 'SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy' + + 'Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF' + + 'ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV' + + '4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv' + + 'C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe' + + 'Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf' + + 'QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy' + + 'iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg', + mac: '5lxYBHQU80M', + ephemeral: '/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14', + }, +}; + +const BACKUP_INFO = { + algorithm: "m.megolm_backup.v1", + version: 1, + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, +}; + +function makeTestClient(sessionStore, cryptoStore) { + const scheduler = [ + "getQueueForEvent", "queueEvent", "removeEventFromQueue", + "setProcessFunction", + ].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {}); + const store = [ + "getRoom", "getRooms", "getUser", "getSyncToken", "scrollback", + "save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom", + "storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter", + "storeFilter", "getSyncAccumulator", "startup", "deleteAllData", + ].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {}); + store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null)); + store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null)); + store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null)); + return new MatrixClient({ + baseUrl: "https://my.home.server", + idBaseUrl: "https://identity.server", + accessToken: "my.access.token", + request: function() {}, // NOP + store: store, + scheduler: scheduler, + userId: "@alice:bar", + deviceId: "device", + sessionStore: sessionStore, + cryptoStore: cryptoStore, + }); +} + +describe("MegolmBackup", function() { + if (!global.Olm) { + logger.warn('Not running megolm backup unit tests: libolm not present'); + return; + } + + let olmDevice; + let mockOlmLib; + let mockCrypto; + let mockStorage; + let sessionStore; + let cryptoStore; + let megolmDecryption; + beforeEach(async function() { + await Olm.init(); + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + + mockCrypto = testUtils.mock(Crypto, 'Crypto'); + mockCrypto.backupKey = new Olm.PkEncryption(); + mockCrypto.backupKey.set_recipient_key( + "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + ); + mockCrypto.backupInfo = BACKUP_INFO; + + mockStorage = new MockStorageApi(); + sessionStore = new WebStorageSessionStore(mockStorage); + cryptoStore = new MemoryCryptoStore(mockStorage); + + olmDevice = new OlmDevice(cryptoStore); + + // we stub out the olm encryption bits + mockOlmLib = {}; + mockOlmLib.ensureOlmSessionsForDevices = expect.createSpy(); + mockOlmLib.encryptMessageForDevice = + expect.createSpy().andReturn(Promise.resolve()); + }); + + describe("backup", function() { + let mockBaseApis; + let realSetTimeout; + + beforeEach(function() { + mockBaseApis = {}; + + megolmDecryption = new MegolmDecryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: mockBaseApis, + roomId: ROOM_ID, + }); + + megolmDecryption.olmlib = mockOlmLib; + + // clobber the setTimeout function to run 100x faster. + // ideally we would use lolex, but we have no oportunity + // to tick the clock between the first try and the retry. + realSetTimeout = global.setTimeout; + global.setTimeout = function(f, n) { + return realSetTimeout(f, n/100); + }; + }); + + afterEach(function() { + global.setTimeout = realSetTimeout; + }); + + it('automatically calls the key back up', function() { + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + + // construct a fake decrypted key event via the use of a mocked + // 'crypto' implementation. + const event = new MatrixEvent({ + type: 'm.room.encrypted', + }); + const decryptedData = { + clearEvent: { + type: 'm.room_key', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + room_id: ROOM_ID, + session_id: groupSession.session_id(), + session_key: groupSession.session_key(), + }, + }, + senderCurve25519Key: "SENDER_CURVE25519", + claimedEd25519Key: "SENDER_ED25519", + }; + + mockCrypto.decryptEvent = function() { + return Promise.resolve(decryptedData); + }; + mockCrypto.cancelRoomKeyRequest = function() {}; + + mockCrypto.backupGroupSession = expect.createSpy(); + + return event.attemptDecryption(mockCrypto).then(() => { + return megolmDecryption.onRoomKeyEvent(event); + }).then(() => { + expect(mockCrypto.backupGroupSession).toHaveBeenCalled(); + }); + }); + + it('sends backups to the server', function() { + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + const ibGroupSession = new Olm.InboundGroupSession(); + ibGroupSession.create(groupSession.session_key()); + + const client = makeTestClient(sessionStore, cryptoStore); + + megolmDecryption = new MegolmDecryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: client, + roomId: ROOM_ID, + }); + + megolmDecryption.olmlib = mockOlmLib; + + return client.initCrypto() + .then(() => { + return cryptoStore.doTxn( + "readwrite", + [cryptoStore.STORE_SESSION], + (txn) => { + cryptoStore.addEndToEndInboundGroupSession( + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + groupSession.session_id(), + { + forwardingCurve25519KeyChain: undefined, + keysClaimed: { + ed25519: "SENDER_ED25519", + }, + room_id: ROOM_ID, + session: ibGroupSession.pickle(olmDevice._pickleKey), + }, + txn); + }); + }) + .then(() => { + client.enableKeyBackup({ + algorithm: "m.megolm_backup.v1", + version: 1, + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, + }); + let numCalls = 0; + return new Promise((resolve, reject) => { + client._http.authedRequest = function( + callback, method, path, queryParams, data, opts, + ) { + ++numCalls; + expect(numCalls).toBeLessThanOrEqualTo(1); + if (numCalls >= 2) { + // exit out of retry loop if there's something wrong + reject(new Error("authedRequest called too many timmes")); + return Promise.resolve({}); + } + expect(method).toBe("PUT"); + expect(path).toBe("/room_keys/keys"); + expect(queryParams.version).toBe(1); + expect(data.rooms[ROOM_ID].sessions).toExist(); + expect(data.rooms[ROOM_ID].sessions).toIncludeKey( + groupSession.session_id(), + ); + resolve(); + return Promise.resolve({}); + }; + client._crypto.backupGroupSession( + "roomId", + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + [], + groupSession.session_id(), + groupSession.session_key(), + ); + }).then(() => { + expect(numCalls).toBe(1); + }); + }); + }); + + it('retries when a backup fails', function() { + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + const ibGroupSession = new Olm.InboundGroupSession(); + ibGroupSession.create(groupSession.session_key()); + + const scheduler = [ + "getQueueForEvent", "queueEvent", "removeEventFromQueue", + "setProcessFunction", + ].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {}); + const store = [ + "getRoom", "getRooms", "getUser", "getSyncToken", "scrollback", + "save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom", + "storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter", + "storeFilter", "getSyncAccumulator", "startup", "deleteAllData", + ].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {}); + store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null)); + store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null)); + store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null)); + const client = new MatrixClient({ + baseUrl: "https://my.home.server", + idBaseUrl: "https://identity.server", + accessToken: "my.access.token", + request: function() {}, // NOP + store: store, + scheduler: scheduler, + userId: "@alice:bar", + deviceId: "device", + sessionStore: sessionStore, + cryptoStore: cryptoStore, + }); + + megolmDecryption = new MegolmDecryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: client, + roomId: ROOM_ID, + }); + + megolmDecryption.olmlib = mockOlmLib; + + return client.initCrypto() + .then(() => { + return cryptoStore.doTxn( + "readwrite", + [cryptoStore.STORE_SESSION], + (txn) => { + cryptoStore.addEndToEndInboundGroupSession( + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + groupSession.session_id(), + { + forwardingCurve25519KeyChain: undefined, + keysClaimed: { + ed25519: "SENDER_ED25519", + }, + room_id: ROOM_ID, + session: ibGroupSession.pickle(olmDevice._pickleKey), + }, + txn); + }); + }) + .then(() => { + client.enableKeyBackup({ + algorithm: "foobar", + version: 1, + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, + }); + let numCalls = 0; + return new Promise((resolve, reject) => { + client._http.authedRequest = function( + callback, method, path, queryParams, data, opts, + ) { + ++numCalls; + expect(numCalls).toBeLessThanOrEqualTo(2); + if (numCalls >= 3) { + // exit out of retry loop if there's something wrong + reject(new Error("authedRequest called too many timmes")); + return Promise.resolve({}); + } + expect(method).toBe("PUT"); + expect(path).toBe("/room_keys/keys"); + expect(queryParams.version).toBe(1); + expect(data.rooms[ROOM_ID].sessions).toExist(); + expect(data.rooms[ROOM_ID].sessions).toIncludeKey( + groupSession.session_id(), + ); + if (numCalls > 1) { + resolve(); + return Promise.resolve({}); + } else { + return Promise.reject( + new Error("this is an expected failure"), + ); + } + }; + client._crypto.backupGroupSession( + "roomId", + "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", + [], + groupSession.session_id(), + groupSession.session_key(), + ); + }).then(() => { + expect(numCalls).toBe(2); + }); + }); + }); + }); + + describe("restore", function() { + let client; + + beforeEach(function() { + client = makeTestClient(sessionStore, cryptoStore); + + megolmDecryption = new MegolmDecryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: client, + roomId: ROOM_ID, + }); + + megolmDecryption.olmlib = mockOlmLib; + + return client.initCrypto(); + }); + + afterEach(function() { + client.stopClient(); + }); + + it('can restore from backup', function() { + client._http.authedRequest = function() { + return Promise.resolve(KEY_BACKUP_DATA); + }; + return client.restoreKeyBackupWithRecoveryKey( + "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", + ROOM_ID, + SESSION_ID, + BACKUP_INFO, + ).then(() => { + return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); + }).then((res) => { + expect(res.clearEvent.content).toEqual('testytest'); + }); + }); + + it('can restore backup by room', function() { + client._http.authedRequest = function() { + return Promise.resolve({ + rooms: { + [ROOM_ID]: { + sessions: { + [SESSION_ID]: KEY_BACKUP_DATA, + }, + }, + }, + }); + }; + return client.restoreKeyBackupWithRecoveryKey( + "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", + null, null, BACKUP_INFO, + ).then(() => { + return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); + }).then((res) => { + expect(res.clearEvent.content).toEqual('testytest'); + }); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/crypto/verification/qr_code.spec.js b/matrix-js-sdk/spec/unit/crypto/verification/qr_code.spec.js new file mode 100644 index 000000000..a64c5d5af --- /dev/null +++ b/matrix-js-sdk/spec/unit/crypto/verification/qr_code.spec.js @@ -0,0 +1,146 @@ +/* +Copyright 2018-2019 New Vector 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. +*/ +import logger from '../../../../src/logger'; + +try { + global.Olm = require('olm'); +} catch (e) { + logger.warn("unable to run device verification tests: libolm not available"); +} + +import expect from 'expect'; +import DeviceInfo from '../../../../lib/crypto/deviceinfo'; + +import {ShowQRCode, ScanQRCode} from '../../../../lib/crypto/verification/QRCode'; + +const Olm = global.Olm; + +describe("QR code verification", function() { + if (!global.Olm) { + logger.warn('Not running device verification tests: libolm not present'); + return; + } + + beforeEach(async function() { + await Olm.init(); + }); + + describe("showing", function() { + it("should emit an event to show a QR code", async function() { + const qrCode = new ShowQRCode({ + getUserId: () => "@alice:example.com", + deviceId: "ABCDEFG", + getDeviceEd25519Key: function() { + return "device+ed25519+key"; + }, + }); + const spy = expect.createSpy().andCall((e) => { + qrCode.done(); + }); + qrCode.on("show_qr_code", spy); + await qrCode.verify(); + expect(spy).toHaveBeenCalledWith({ + url: "https://matrix.to/#/@alice:example.com?device=ABCDEFG" + + "&action=verify&key_ed25519%3AABCDEFG=device%2Bed25519%2Bkey", + }); + }); + }); + + describe("scanning", function() { + const QR_CODE_URL = "https://matrix.to/#/@alice:example.com?device=ABCDEFG" + + "&action=verify&key_ed25519%3AABCDEFG=device%2Bed25519%2Bkey"; + it("should verify when a QR code is sent", async function() { + const device = DeviceInfo.fromStorage( + { + algorithms: [], + keys: { + "curve25519:ABCDEFG": "device+curve25519+key", + "ed25519:ABCDEFG": "device+ed25519+key", + }, + verified: false, + known: false, + unsigned: {}, + }, + "ABCDEFG", + ); + const client = { + getStoredDevice: expect.createSpy().andReturn(device), + setDeviceVerified: expect.createSpy(), + }; + const qrCode = new ScanQRCode(client); + qrCode.on("confirm_user_id", ({userId, confirm}) => { + if (userId === "@alice:example.com") { + confirm(); + } else { + qrCode.cancel(new Error("Incorrect user")); + } + }); + qrCode.on("scan", ({done}) => { + done(QR_CODE_URL); + }); + await qrCode.verify(); + expect(client.getStoredDevice) + .toHaveBeenCalledWith("@alice:example.com", "ABCDEFG"); + expect(client.setDeviceVerified) + .toHaveBeenCalledWith("@alice:example.com", "ABCDEFG"); + }); + + it("should error when the user ID doesn't match", async function() { + const client = { + getStoredDevice: expect.createSpy(), + setDeviceVerified: expect.createSpy(), + }; + const qrCode = new ScanQRCode(client, "@bob:example.com", "ABCDEFG"); + qrCode.on("scan", ({done}) => { + done(QR_CODE_URL); + }); + const spy = expect.createSpy(); + await qrCode.verify().catch(spy); + expect(spy).toHaveBeenCalled(); + expect(client.getStoredDevice).toNotHaveBeenCalled(); + expect(client.setDeviceVerified).toNotHaveBeenCalled(); + }); + + it("should error if the key doesn't match", async function() { + const device = DeviceInfo.fromStorage( + { + algorithms: [], + keys: { + "curve25519:ABCDEFG": "device+curve25519+key", + "ed25519:ABCDEFG": "a+different+device+ed25519+key", + }, + verified: false, + known: false, + unsigned: {}, + }, + "ABCDEFG", + ); + const client = { + getStoredDevice: expect.createSpy().andReturn(device), + setDeviceVerified: expect.createSpy(), + }; + const qrCode = new ScanQRCode(client, "@alice:example.com", "ABCDEFG"); + qrCode.on("scan", ({done}) => { + done(QR_CODE_URL); + }); + const spy = expect.createSpy(); + await qrCode.verify().catch(spy); + expect(spy).toHaveBeenCalled(); + expect(client.getStoredDevice).toHaveBeenCalled(); + expect(client.setDeviceVerified).toNotHaveBeenCalled(); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/crypto/verification/request.spec.js b/matrix-js-sdk/spec/unit/crypto/verification/request.spec.js new file mode 100644 index 000000000..ef0ea8586 --- /dev/null +++ b/matrix-js-sdk/spec/unit/crypto/verification/request.spec.js @@ -0,0 +1,82 @@ +/* +Copyright 2019 New Vector 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. +*/ +import logger from '../../../../src/logger'; + +try { + global.Olm = require('olm'); +} catch (e) { + logger.warn("unable to run device verification tests: libolm not available"); +} + +import expect from 'expect'; + +import {verificationMethods} from '../../../../lib/crypto'; + +import SAS from '../../../../lib/crypto/verification/SAS'; + +const Olm = global.Olm; + +import {makeTestClients} from './util'; + +describe("verification request", function() { + if (!global.Olm) { + logger.warn('Not running device verification unit tests: libolm not present'); + return; + } + + beforeEach(async function() { + await Olm.init(); + }); + + it("should request and accept a verification", async function() { + const [alice, bob] = await makeTestClients( + [ + {userId: "@alice:example.com", deviceId: "Osborne2"}, + {userId: "@bob:example.com", deviceId: "Dynabook"}, + ], + { + verificationMethods: [verificationMethods.SAS], + }, + ); + alice._crypto._deviceList.getRawStoredDevicesForUser = function() { + return { + Dynabook: { + keys: { + "ed25519:Dynabook": "bob+base64+ed25519+key", + }, + }, + }; + }; + alice.downloadKeys = () => { + return Promise.resolve(); + }; + bob.downloadKeys = () => { + return Promise.resolve(); + }; + bob.on("crypto.verification.request", (request) => { + const bobVerifier = request.beginKeyVerification(verificationMethods.SAS); + bobVerifier.verify(); + + // XXX: Private function access (but it's a test, so we're okay) + bobVerifier._endTimer(); + }); + const aliceVerifier = await alice.requestVerification("@bob:example.com"); + expect(aliceVerifier).toBeAn(SAS); + + // XXX: Private function access (but it's a test, so we're okay) + aliceVerifier._endTimer(); + }); +}); diff --git a/matrix-js-sdk/spec/unit/crypto/verification/sas.spec.js b/matrix-js-sdk/spec/unit/crypto/verification/sas.spec.js new file mode 100644 index 000000000..16b260bc2 --- /dev/null +++ b/matrix-js-sdk/spec/unit/crypto/verification/sas.spec.js @@ -0,0 +1,280 @@ +/* +Copyright 2018-2019 New Vector 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. +*/ +import logger from '../../../../src/logger'; + +try { + global.Olm = require('olm'); +} catch (e) { + logger.warn("unable to run device verification tests: libolm not available"); +} + +import expect from 'expect'; + +import sdk from '../../../..'; + +import {verificationMethods} from '../../../../lib/crypto'; +import DeviceInfo from '../../../../lib/crypto/deviceinfo'; + +import SAS from '../../../../lib/crypto/verification/SAS'; + +const Olm = global.Olm; + +const MatrixEvent = sdk.MatrixEvent; + +import {makeTestClients} from './util'; + +describe("SAS verification", function() { + if (!global.Olm) { + logger.warn('Not running device verification unit tests: libolm not present'); + return; + } + + beforeEach(async function() { + await Olm.init(); + }); + + it("should error on an unexpected event", async function() { + const sas = new SAS({}, "@alice:example.com", "ABCDEFG"); + sas.handleEvent(new MatrixEvent({ + sender: "@alice:example.com", + type: "es.inquisition", + content: {}, + })); + const spy = expect.createSpy(); + await sas.verify() + .catch(spy); + expect(spy).toHaveBeenCalled(); + + // Cancel the SAS for cleanup (we started a verification, so abort) + sas.cancel(); + }); + + describe("verification", function() { + let alice; + let bob; + let aliceSasEvent; + let bobSasEvent; + let aliceVerifier; + let bobPromise; + + beforeEach(async function() { + [alice, bob] = await makeTestClients( + [ + {userId: "@alice:example.com", deviceId: "Osborne2"}, + {userId: "@bob:example.com", deviceId: "Dynabook"}, + ], + { + verificationMethods: [verificationMethods.SAS], + }, + ); + + alice.setDeviceVerified = expect.createSpy(); + alice.getDeviceEd25519Key = () => { + return "alice+base64+ed25519+key"; + }; + alice.getStoredDevice = () => { + return DeviceInfo.fromStorage( + { + keys: { + "ed25519:Dynabook": "bob+base64+ed25519+key", + }, + }, + "Dynabook", + ); + }; + alice.downloadKeys = () => { + return Promise.resolve(); + }; + + bob.setDeviceVerified = expect.createSpy(); + bob.getStoredDevice = () => { + return DeviceInfo.fromStorage( + { + keys: { + "ed25519:Osborne2": "alice+base64+ed25519+key", + }, + }, + "Osborne2", + ); + }; + bob.getDeviceEd25519Key = () => { + return "bob+base64+ed25519+key"; + }; + bob.downloadKeys = () => { + return Promise.resolve(); + }; + + aliceSasEvent = null; + bobSasEvent = null; + + bobPromise = new Promise((resolve, reject) => { + bob.on("crypto.verification.start", (verifier) => { + verifier.on("show_sas", (e) => { + if (!e.sas.emoji || !e.sas.decimal) { + e.cancel(); + } else if (!aliceSasEvent) { + bobSasEvent = e; + } else { + try { + expect(e.sas).toEqual(aliceSasEvent.sas); + e.confirm(); + aliceSasEvent.confirm(); + } catch (error) { + e.mismatch(); + aliceSasEvent.mismatch(); + } + } + }); + resolve(verifier); + }); + }); + + aliceVerifier = alice.beginKeyVerification( + verificationMethods.SAS, bob.getUserId(), bob.deviceId, + ); + aliceVerifier.on("show_sas", (e) => { + if (!e.sas.emoji || !e.sas.decimal) { + e.cancel(); + } else if (!bobSasEvent) { + aliceSasEvent = e; + } else { + try { + expect(e.sas).toEqual(bobSasEvent.sas); + e.confirm(); + bobSasEvent.confirm(); + } catch (error) { + e.mismatch(); + bobSasEvent.mismatch(); + } + } + }); + }); + + it("should verify a key", async function() { + let macMethod; + let keyAgreement; + const origSendToDevice = alice.sendToDevice; + bob.sendToDevice = function(type, map) { + if (type === "m.key.verification.accept") { + macMethod = map[alice.getUserId()][alice.deviceId] + .message_authentication_code; + keyAgreement = map[alice.client.getUserId()][alice.client.deviceId] + .key_agreement_protocol; + } + return origSendToDevice.call(this, type, map); + }; + + await Promise.all([ + aliceVerifier.verify(), + bobPromise.then((verifier) => verifier.verify()), + ]); + + // make sure that it uses the preferred method + expect(macMethod).toBe("hkdf-hmac-sha256"); + expect(keyAgreement).toBe("curve25519-hkdf-sha256"); + + // make sure Alice and Bob verified each other + expect(alice.setDeviceVerified) + .toHaveBeenCalledWith(bob.getUserId(), bob.deviceId); + expect(bob.setDeviceVerified) + .toHaveBeenCalledWith(alice.getUserId(), alice.deviceId); + }); + + it("should be able to verify using the old MAC", async function() { + // pretend that Alice can only understand the old (incorrect) MAC, + // and make sure that she can still verify with Bob + let macMethod; + const origSendToDevice = alice.sendToDevice; + alice.sendToDevice = function(type, map) { + if (type === "m.key.verification.start") { + // Note: this modifies not only the message that Bob + // receives, but also the copy of the message that Alice + // has, since it is the same object. If this does not + // happen, the verification will fail due to a hash + // commitment mismatch. + map[bob.getUserId()][bob.deviceId] + .message_authentication_codes = ['hmac-sha256']; + } + return origSendToDevice.call(this, type, map); + }; + bob.sendToDevice = function(type, map) { + if (type === "m.key.verification.accept") { + macMethod = map[alice.getUserId()][alice.deviceId] + .message_authentication_code; + } + return origSendToDevice.call(this, type, map); + }; + + await Promise.all([ + aliceVerifier.verify(), + bobPromise.then((verifier) => verifier.verify()), + ]); + + expect(macMethod).toBe("hmac-sha256"); + + expect(alice.setDeviceVerified) + .toHaveBeenCalledWith(bob.getUserId(), bob.deviceId); + expect(bob.setDeviceVerified) + .toHaveBeenCalledWith(alice.getUserId(), alice.deviceId); + }); + }); + + it("should send a cancellation message on error", async function() { + const [alice, bob] = await makeTestClients( + [ + {userId: "@alice:example.com", deviceId: "Osborne2"}, + {userId: "@bob:example.com", deviceId: "Dynabook"}, + ], + { + verificationMethods: [verificationMethods.SAS], + }, + ); + alice.setDeviceVerified = expect.createSpy(); + alice.downloadKeys = () => { + return Promise.resolve(); + }; + bob.setDeviceVerified = expect.createSpy(); + bob.downloadKeys = () => { + return Promise.resolve(); + }; + + const bobPromise = new Promise((resolve, reject) => { + bob.on("crypto.verification.start", (verifier) => { + verifier.on("show_sas", (e) => { + e.mismatch(); + }); + resolve(verifier); + }); + }); + + const aliceVerifier = alice.beginKeyVerification( + verificationMethods.SAS, bob.getUserId(), bob.deviceId, + ); + + const aliceSpy = expect.createSpy(); + const bobSpy = expect.createSpy(); + await Promise.all([ + aliceVerifier.verify().catch(aliceSpy), + bobPromise.then((verifier) => verifier.verify()).catch(bobSpy), + ]); + expect(aliceSpy).toHaveBeenCalled(); + expect(bobSpy).toHaveBeenCalled(); + expect(alice.setDeviceVerified) + .toNotHaveBeenCalled(); + expect(bob.setDeviceVerified) + .toNotHaveBeenCalled(); + }); +}); diff --git a/matrix-js-sdk/spec/unit/crypto/verification/util.js b/matrix-js-sdk/spec/unit/crypto/verification/util.js new file mode 100644 index 000000000..6e5622f3e --- /dev/null +++ b/matrix-js-sdk/spec/unit/crypto/verification/util.js @@ -0,0 +1,63 @@ +/* +Copyright 2019 New Vector 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. +*/ + +import TestClient from '../../../TestClient'; + +import sdk from '../../../..'; +const MatrixEvent = sdk.MatrixEvent; + +export async function makeTestClients(userInfos, options) { + const clients = []; + const clientMap = {}; + const sendToDevice = function(type, map) { + // console.log(this.getUserId(), "sends", type, map); + for (const [userId, devMap] of Object.entries(map)) { + if (userId in clientMap) { + for (const [deviceId, msg] of Object.entries(devMap)) { + if (deviceId in clientMap[userId]) { + const event = new MatrixEvent({ + sender: this.getUserId(), // eslint-disable-line babel/no-invalid-this + type: type, + content: msg, + }); + setTimeout( + () => clientMap[userId][deviceId] + .emit("toDeviceEvent", event), + 0, + ); + } + } + } + } + }; + + for (const userInfo of userInfos) { + const client = (new TestClient( + userInfo.userId, userInfo.deviceId, undefined, undefined, + options, + )).client; + if (!(userInfo.userId in clientMap)) { + clientMap[userInfo.userId] = {}; + } + clientMap[userInfo.userId][userInfo.deviceId] = client; + client.sendToDevice = sendToDevice; + clients.push(client); + } + + await Promise.all(clients.map((client) => client.initCrypto())); + + return clients; +} diff --git a/matrix-js-sdk/spec/unit/event-timeline.spec.js b/matrix-js-sdk/spec/unit/event-timeline.spec.js new file mode 100644 index 000000000..85f8a8215 --- /dev/null +++ b/matrix-js-sdk/spec/unit/event-timeline.spec.js @@ -0,0 +1,378 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const EventTimeline = sdk.EventTimeline; +const utils = require("../test-utils"); + +function mockRoomStates(timeline) { + timeline._startState = utils.mock(sdk.RoomState, "startState"); + timeline._endState = utils.mock(sdk.RoomState, "endState"); +} + +import expect from 'expect'; + +describe("EventTimeline", function() { + const roomId = "!foo:bar"; + const userA = "@alice:bar"; + const userB = "@bertha:bar"; + let timeline; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + + // XXX: this is a horrid hack; should use sinon or something instead to mock + const timelineSet = { room: { roomId: roomId }}; + timelineSet.room.getUnfilteredTimelineSet = function() { + return timelineSet; + }; + + timeline = new EventTimeline(timelineSet); + }); + + describe("construction", function() { + it("getRoomId should get room id", function() { + const v = timeline.getRoomId(); + expect(v).toEqual(roomId); + }); + }); + + describe("initialiseState", function() { + beforeEach(function() { + mockRoomStates(timeline); + }); + + it("should copy state events to start and end state", function() { + const events = [ + utils.mkMembership({ + room: roomId, mship: "invite", user: userB, skey: userA, + event: true, + }), + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userB, + event: true, + content: { name: "New room" }, + }), + ]; + timeline.initialiseState(events); + expect(timeline._startState.setStateEvents).toHaveBeenCalledWith( + events, + ); + expect(timeline._endState.setStateEvents).toHaveBeenCalledWith( + events, + ); + }); + + it("should raise an exception if called after events are added", function() { + const event = + utils.mkMessage({ + room: roomId, user: userA, msg: "Adam stole the plushies", + event: true, + }); + + const state = [ + utils.mkMembership({ + room: roomId, mship: "invite", user: userB, skey: userA, + event: true, + }), + ]; + + expect(function() { + timeline.initialiseState(state); + }).toNotThrow(); + timeline.addEvent(event, false); + expect(function() { + timeline.initialiseState(state); + }).toThrow(); + }); + }); + + describe("paginationTokens", function() { + it("pagination tokens should start null", function() { + expect(timeline.getPaginationToken(EventTimeline.BACKWARDS)).toBe(null); + expect(timeline.getPaginationToken(EventTimeline.FORWARDS)).toBe(null); + }); + + it("setPaginationToken should set token", function() { + timeline.setPaginationToken("back", EventTimeline.BACKWARDS); + timeline.setPaginationToken("fwd", EventTimeline.FORWARDS); + expect(timeline.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("back"); + expect(timeline.getPaginationToken(EventTimeline.FORWARDS)).toEqual("fwd"); + }); + }); + + + describe("neighbouringTimelines", function() { + it("neighbouring timelines should start null", function() { + expect(timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)).toBe(null); + expect(timeline.getNeighbouringTimeline(EventTimeline.FORWARDS)).toBe(null); + }); + + it("setNeighbouringTimeline should set neighbour", function() { + const prev = {a: "a"}; + const next = {b: "b"}; + timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS); + timeline.setNeighbouringTimeline(next, EventTimeline.FORWARDS); + expect(timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)).toBe(prev); + expect(timeline.getNeighbouringTimeline(EventTimeline.FORWARDS)).toBe(next); + }); + + it("setNeighbouringTimeline should throw if called twice", function() { + const prev = {a: "a"}; + const next = {b: "b"}; + expect(function() { + timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS); + }).toNotThrow(); + expect(timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) + .toBe(prev); + expect(function() { + timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS); + }).toThrow(); + + expect(function() { + timeline.setNeighbouringTimeline(next, EventTimeline.FORWARDS); + }).toNotThrow(); + expect(timeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) + .toBe(next); + expect(function() { + timeline.setNeighbouringTimeline(next, EventTimeline.FORWARDS); + }).toThrow(); + }); + }); + + describe("addEvent", function() { + beforeEach(function() { + mockRoomStates(timeline); + }); + + const events = [ + utils.mkMessage({ + room: roomId, user: userA, msg: "hungry hungry hungry", + event: true, + }), + utils.mkMessage({ + room: roomId, user: userB, msg: "nom nom nom", + event: true, + }), + ]; + + it("should be able to add events to the end", function() { + timeline.addEvent(events[0], false); + const initialIndex = timeline.getBaseIndex(); + timeline.addEvent(events[1], false); + expect(timeline.getBaseIndex()).toEqual(initialIndex); + expect(timeline.getEvents().length).toEqual(2); + expect(timeline.getEvents()[0]).toEqual(events[0]); + expect(timeline.getEvents()[1]).toEqual(events[1]); + }); + + it("should be able to add events to the start", function() { + timeline.addEvent(events[0], true); + const initialIndex = timeline.getBaseIndex(); + timeline.addEvent(events[1], true); + expect(timeline.getBaseIndex()).toEqual(initialIndex + 1); + expect(timeline.getEvents().length).toEqual(2); + expect(timeline.getEvents()[0]).toEqual(events[1]); + expect(timeline.getEvents()[1]).toEqual(events[0]); + }); + + it("should set event.sender for new and old events", function() { + const sentinel = { + userId: userA, + membership: "join", + name: "Alice", + }; + const oldSentinel = { + userId: userA, + membership: "join", + name: "Old Alice", + }; + timeline.getState(EventTimeline.FORWARDS).getSentinelMember + .andCall(function(uid) { + if (uid === userA) { + return sentinel; + } + return null; + }); + timeline.getState(EventTimeline.BACKWARDS).getSentinelMember + .andCall(function(uid) { + if (uid === userA) { + return oldSentinel; + } + return null; + }); + + const newEv = utils.mkEvent({ + type: "m.room.name", room: roomId, user: userA, event: true, + content: { name: "New Room Name" }, + }); + const oldEv = utils.mkEvent({ + type: "m.room.name", room: roomId, user: userA, event: true, + content: { name: "Old Room Name" }, + }); + + timeline.addEvent(newEv, false); + expect(newEv.sender).toEqual(sentinel); + timeline.addEvent(oldEv, true); + expect(oldEv.sender).toEqual(oldSentinel); + }); + + it("should set event.target for new and old m.room.member events", + function() { + const sentinel = { + userId: userA, + membership: "join", + name: "Alice", + }; + const oldSentinel = { + userId: userA, + membership: "join", + name: "Old Alice", + }; + timeline.getState(EventTimeline.FORWARDS).getSentinelMember + .andCall(function(uid) { + if (uid === userA) { + return sentinel; + } + return null; + }); + timeline.getState(EventTimeline.BACKWARDS).getSentinelMember + .andCall(function(uid) { + if (uid === userA) { + return oldSentinel; + } + return null; + }); + + const newEv = utils.mkMembership({ + room: roomId, mship: "invite", user: userB, skey: userA, event: true, + }); + const oldEv = utils.mkMembership({ + room: roomId, mship: "ban", user: userB, skey: userA, event: true, + }); + timeline.addEvent(newEv, false); + expect(newEv.target).toEqual(sentinel); + timeline.addEvent(oldEv, true); + expect(oldEv.target).toEqual(oldSentinel); + }); + + 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, + }), + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userB, event: true, + content: { + name: "New room", + }, + }), + ]; + + timeline.addEvent(events[0], false); + timeline.addEvent(events[1], false); + + expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). + toHaveBeenCalledWith([events[0]]); + expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). + toHaveBeenCalledWith([events[1]]); + + expect(events[0].forwardLooking).toBe(true); + expect(events[1].forwardLooking).toBe(true); + + expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). + toNotHaveBeenCalled(); + }); + + + it("should call setStateEvents on the right RoomState with the right " + + "forwardLooking value for old events", function() { + const events = [ + utils.mkMembership({ + room: roomId, mship: "invite", user: userB, skey: userA, event: true, + }), + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userB, event: true, + content: { + name: "New room", + }, + }), + ]; + + timeline.addEvent(events[0], true); + timeline.addEvent(events[1], true); + + expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). + toHaveBeenCalledWith([events[0]]); + expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). + toHaveBeenCalledWith([events[1]]); + + expect(events[0].forwardLooking).toBe(false); + expect(events[1].forwardLooking).toBe(false); + + expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). + toNotHaveBeenCalled(); + }); + }); + + describe("removeEvent", function() { + const events = [ + utils.mkMessage({ + room: roomId, user: userA, msg: "hungry hungry hungry", + event: true, + }), + utils.mkMessage({ + room: roomId, user: userB, msg: "nom nom nom", + event: true, + }), + utils.mkMessage({ + room: roomId, user: userB, msg: "piiie", + event: true, + }), + ]; + + it("should remove events", function() { + timeline.addEvent(events[0], false); + timeline.addEvent(events[1], false); + expect(timeline.getEvents().length).toEqual(2); + + let ev = timeline.removeEvent(events[0].getId()); + expect(ev).toBe(events[0]); + expect(timeline.getEvents().length).toEqual(1); + + ev = timeline.removeEvent(events[1].getId()); + expect(ev).toBe(events[1]); + expect(timeline.getEvents().length).toEqual(0); + }); + + it("should update baseIndex", function() { + timeline.addEvent(events[0], false); + timeline.addEvent(events[1], true); + timeline.addEvent(events[2], false); + expect(timeline.getEvents().length).toEqual(3); + expect(timeline.getBaseIndex()).toEqual(1); + + timeline.removeEvent(events[2].getId()); + expect(timeline.getEvents().length).toEqual(2); + expect(timeline.getBaseIndex()).toEqual(1); + + timeline.removeEvent(events[1].getId()); + expect(timeline.getEvents().length).toEqual(1); + expect(timeline.getBaseIndex()).toEqual(0); + }); + + // this is basically https://github.com/vector-im/vector-web/issues/937 + // - removing the last event got baseIndex into such a state that + // further addEvent(ev, false) calls made the index increase. + it("should not make baseIndex assplode when removing the last event", + function() { + timeline.addEvent(events[0], true); + timeline.removeEvent(events[0].getId()); + const initialIndex = timeline.getBaseIndex(); + timeline.addEvent(events[1], false); + timeline.addEvent(events[2], false); + expect(timeline.getBaseIndex()).toEqual(initialIndex); + expect(timeline.getEvents().length).toEqual(2); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/event.spec.js b/matrix-js-sdk/spec/unit/event.spec.js new file mode 100644 index 000000000..add962d88 --- /dev/null +++ b/matrix-js-sdk/spec/unit/event.spec.js @@ -0,0 +1,83 @@ +/* +Copyright 2017 New Vector 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. +*/ + +import sdk from '../..'; +const MatrixEvent = sdk.MatrixEvent; + +import testUtils from '../test-utils'; + +import expect from 'expect'; +import Promise from 'bluebird'; +import logger from '../../src/logger'; + +describe("MatrixEvent", () => { + beforeEach(function() { + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + }); + + describe(".attemptDecryption", () => { + let encryptedEvent; + + beforeEach(() => { + encryptedEvent = new MatrixEvent({ + id: 'test_encrypted_event', + type: 'm.room.encrypted', + content: { + ciphertext: 'secrets', + }, + }); + }); + + it('should retry decryption if a retry is queued', () => { + let callCount = 0; + + let prom2; + + const crypto = { + decryptEvent: function() { + ++callCount; + logger.log(`decrypt: ${callCount}`); + if (callCount == 1) { + // schedule a second decryption attempt while + // the first one is still running. + prom2 = encryptedEvent.attemptDecryption(crypto); + + const error = new Error("nope"); + error.name = 'DecryptionError'; + return Promise.reject(error); + } else { + expect(prom2.isFulfilled()).toBe( + false, 'second attemptDecryption resolved too soon'); + + return Promise.resolve({ + clearEvent: { + type: 'm.room.message', + }, + }); + } + }, + }; + + return encryptedEvent.attemptDecryption(crypto).then(() => { + expect(callCount).toEqual(2); + expect(encryptedEvent.getType()).toEqual('m.room.message'); + + // make sure the second attemptDecryption resolves + return prom2; + }); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/filter.spec.js b/matrix-js-sdk/spec/unit/filter.spec.js new file mode 100644 index 000000000..249eaa171 --- /dev/null +++ b/matrix-js-sdk/spec/unit/filter.spec.js @@ -0,0 +1,53 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const Filter = sdk.Filter; +const utils = require("../test-utils"); + +import expect from 'expect'; + +describe("Filter", function() { + const filterId = "f1lt3ring15g00d4ursoul"; + const userId = "@sir_arthur_david:humming.tiger"; + let filter; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + filter = new Filter(userId); + }); + + describe("fromJson", function() { + it("create a new Filter from the provided values", function() { + const definition = { + event_fields: ["type", "content"], + }; + const f = Filter.fromJson(userId, filterId, definition); + expect(f.getDefinition()).toEqual(definition); + expect(f.userId).toEqual(userId); + expect(f.filterId).toEqual(filterId); + }); + }); + + describe("setTimelineLimit", function() { + it("should set room.timeline.limit of the filter definition", function() { + filter.setTimelineLimit(10); + expect(filter.getDefinition()).toEqual({ + room: { + timeline: { + limit: 10, + }, + }, + }); + }); + }); + + describe("setDefinition/getDefinition", function() { + it("should set and get the filter body", function() { + const definition = { + event_format: "client", + }; + filter.setDefinition(definition); + expect(filter.getDefinition()).toEqual(definition); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/interactive-auth.spec.js b/matrix-js-sdk/spec/unit/interactive-auth.spec.js new file mode 100644 index 000000000..110cb714c --- /dev/null +++ b/matrix-js-sdk/spec/unit/interactive-auth.spec.js @@ -0,0 +1,157 @@ +/* +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. +*/ +"use strict"; + +import 'source-map-support/register'; +import Promise from 'bluebird'; +const sdk = require("../.."); +const utils = require("../test-utils"); + +const InteractiveAuth = sdk.InteractiveAuth; +const MatrixError = sdk.MatrixError; + +import expect from 'expect'; +import logger from '../../src/logger'; + +// Trivial client object to test interactive auth +// (we do not need TestClient here) +class FakeClient { + generateClientSecret() { + return "testcl1Ent5EcreT"; + } +} + +describe("InteractiveAuth", function() { + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + }); + + it("should start an auth stage and complete it", function(done) { + const doRequest = expect.createSpy(); + const stateUpdated = expect.createSpy(); + + const ia = new InteractiveAuth({ + matrixClient: new FakeClient(), + doRequest: doRequest, + stateUpdated: stateUpdated, + authData: { + session: "sessionId", + flows: [ + { stages: ["logintype"] }, + ], + params: { + "logintype": { param: "aa" }, + }, + }, + }); + + expect(ia.getSessionId()).toEqual("sessionId"); + expect(ia.getStageParams("logintype")).toEqual({ + param: "aa", + }); + + // first we expect a call here + stateUpdated.andCall(function(stage) { + logger.log('aaaa'); + expect(stage).toEqual("logintype"); + ia.submitAuthDict({ + type: "logintype", + foo: "bar", + }); + }); + + // .. which should trigger a call here + const requestRes = {"a": "b"}; + doRequest.andCall(function(authData) { + logger.log('cccc'); + expect(authData).toEqual({ + session: "sessionId", + type: "logintype", + foo: "bar", + }); + return Promise.resolve(requestRes); + }); + + ia.attemptAuth().then(function(res) { + expect(res).toBe(requestRes); + expect(doRequest.calls.length).toEqual(1); + expect(stateUpdated.calls.length).toEqual(1); + }).nodeify(done); + }); + + it("should make a request if no authdata is provided", function(done) { + const doRequest = expect.createSpy(); + const stateUpdated = expect.createSpy(); + + const ia = new InteractiveAuth({ + matrixClient: new FakeClient(), + stateUpdated: stateUpdated, + doRequest: doRequest, + }); + + expect(ia.getSessionId()).toBe(undefined); + expect(ia.getStageParams("logintype")).toBe(undefined); + + // first we expect a call to doRequest + doRequest.andCall(function(authData) { + logger.log("request1", authData); + expect(authData).toEqual({}); + const err = new MatrixError({ + session: "sessionId", + flows: [ + { stages: ["logintype"] }, + ], + params: { + "logintype": { param: "aa" }, + }, + }); + err.httpStatus = 401; + throw err; + }); + + // .. which should be followed by a call to stateUpdated + const requestRes = {"a": "b"}; + stateUpdated.andCall(function(stage) { + expect(stage).toEqual("logintype"); + expect(ia.getSessionId()).toEqual("sessionId"); + expect(ia.getStageParams("logintype")).toEqual({ + param: "aa", + }); + + // submitAuthDict should trigger another call to doRequest + doRequest.andCall(function(authData) { + logger.log("request2", authData); + expect(authData).toEqual({ + session: "sessionId", + type: "logintype", + foo: "bar", + }); + return Promise.resolve(requestRes); + }); + + ia.submitAuthDict({ + type: "logintype", + foo: "bar", + }); + }); + + ia.attemptAuth().then(function(res) { + expect(res).toBe(requestRes); + expect(doRequest.calls.length).toEqual(2); + expect(stateUpdated.calls.length).toEqual(1); + }).nodeify(done); + }); +}); diff --git a/matrix-js-sdk/spec/unit/login.spec.js b/matrix-js-sdk/spec/unit/login.spec.js new file mode 100644 index 000000000..97c0dade2 --- /dev/null +++ b/matrix-js-sdk/spec/unit/login.spec.js @@ -0,0 +1,25 @@ +import expect from 'expect'; +import TestClient from '../TestClient'; + +describe('Login request', function() { + let client; + + beforeEach(function() { + client = new TestClient(); + }); + + afterEach(function() { + client.stop(); + }); + + it('should store "access_token" and "user_id" if in response', async function() { + const response = { user_id: 1, access_token: Date.now().toString(16) }; + + client.httpBackend.when('POST', '/login').respond(200, response); + client.httpBackend.flush('/login', 1, 100); + await client.client.login('m.login.any', { user: 'test', password: '12312za' }); + + expect(client.client.getAccessToken()).toBe(response.access_token); + expect(client.client.getUserId()).toBe(response.user_id); + }); +}); diff --git a/matrix-js-sdk/spec/unit/matrix-client.spec.js b/matrix-js-sdk/spec/unit/matrix-client.spec.js new file mode 100644 index 000000000..5d870754d --- /dev/null +++ b/matrix-js-sdk/spec/unit/matrix-client.spec.js @@ -0,0 +1,539 @@ +"use strict"; +import 'source-map-support/register'; +import Promise from 'bluebird'; +const sdk = require("../.."); +const MatrixClient = sdk.MatrixClient; +const utils = require("../test-utils"); + +import expect from 'expect'; +import lolex from 'lolex'; +import logger from '../../src/logger'; + +describe("MatrixClient", function() { + const userId = "@alice:bar"; + const identityServerUrl = "https://identity.server"; + const identityServerDomain = "identity.server"; + let client; + let store; + let scheduler; + let clock; + + const KEEP_ALIVE_PATH = "/_matrix/client/versions"; + + const PUSH_RULES_RESPONSE = { + method: "GET", + path: "/pushrules/", + data: {}, + }; + + const FILTER_PATH = "/user/" + encodeURIComponent(userId) + "/filter"; + + const FILTER_RESPONSE = { + method: "POST", + path: FILTER_PATH, + data: { filter_id: "f1lt3r" }, + }; + + const SYNC_DATA = { + next_batch: "s_5_3", + presence: { events: [] }, + rooms: {}, + }; + + const SYNC_RESPONSE = { + method: "GET", + path: "/sync", + data: SYNC_DATA, + }; + + let httpLookups = [ + // items are objects which look like: + // { + // method: "GET", + // path: "/initialSync", + // data: {}, + // error: { errcode: M_FORBIDDEN } // if present will reject promise, + // expectBody: {} // additional expects on the body + // expectQueryParams: {} // additional expects on query params + // thenCall: function(){} // function to call *AFTER* returning response. + // } + // items are popped off when processed and block if no items left. + ]; + let acceptKeepalives; + let pendingLookup = null; + function httpReq(cb, method, path, qp, data, prefix) { + if (path === KEEP_ALIVE_PATH && acceptKeepalives) { + return Promise.resolve(); + } + const next = httpLookups.shift(); + const logLine = ( + "MatrixClient[UT] RECV " + method + " " + path + " " + + "EXPECT " + (next ? next.method : next) + " " + (next ? next.path : next) + ); + logger.log(logLine); + + if (!next) { // no more things to return + if (pendingLookup) { + if (pendingLookup.method === method && pendingLookup.path === path) { + return pendingLookup.promise; + } + // >1 pending thing, and they are different, whine. + expect(false).toBe( + true, ">1 pending request. You should probably handle them. " + + "PENDING: " + JSON.stringify(pendingLookup) + " JUST GOT: " + + method + " " + path, + ); + } + pendingLookup = { + promise: Promise.defer().promise, + method: method, + path: path, + }; + return pendingLookup.promise; + } + if (next.path === path && next.method === method) { + logger.log( + "MatrixClient[UT] Matched. Returning " + + (next.error ? "BAD" : "GOOD") + " response", + ); + if (next.expectBody) { + expect(next.expectBody).toEqual(data); + } + if (next.expectQueryParams) { + Object.keys(next.expectQueryParams).forEach(function(k) { + expect(qp[k]).toEqual(next.expectQueryParams[k]); + }); + } + + if (next.thenCall) { + process.nextTick(next.thenCall, 0); // next tick so we return first. + } + + if (next.error) { + return Promise.reject({ + errcode: next.error.errcode, + httpStatus: next.error.httpStatus, + name: next.error.errcode, + message: "Expected testing error", + data: next.error, + }); + } + return Promise.resolve(next.data); + } + expect(true).toBe(false, "Expected different request. " + logLine); + return Promise.defer().promise; + } + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + clock = lolex.install(); + scheduler = [ + "getQueueForEvent", "queueEvent", "removeEventFromQueue", + "setProcessFunction", + ].reduce((r, k) => { r[k] = expect.createSpy(); return r; }, {}); + store = [ + "getRoom", "getRooms", "getUser", "getSyncToken", "scrollback", + "save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom", "storeUser", + "getFilterIdByName", "setFilterIdByName", "getFilter", "storeFilter", + "getSyncAccumulator", "startup", "deleteAllData", + ].reduce((r, k) => { r[k] = expect.createSpy(); return r; }, {}); + store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null)); + store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null)); + store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null)); + store.getClientOptions = expect.createSpy().andReturn(Promise.resolve(null)); + store.storeClientOptions = expect.createSpy().andReturn(Promise.resolve(null)); + store.isNewlyCreated = expect.createSpy().andReturn(Promise.resolve(true)); + client = new MatrixClient({ + baseUrl: "https://my.home.server", + idBaseUrl: identityServerUrl, + accessToken: "my.access.token", + request: function() {}, // NOP + store: store, + scheduler: scheduler, + userId: userId, + }); + // FIXME: We shouldn't be yanking _http like this. + client._http = [ + "authedRequest", "authedRequestWithPrefix", "getContentUri", + "request", "requestWithPrefix", "uploadContent", + ].reduce((r, k) => { r[k] = expect.createSpy(); return r; }, {}); + client._http.authedRequest.andCall(httpReq); + client._http.authedRequestWithPrefix.andCall(httpReq); + client._http.requestWithPrefix.andCall(httpReq); + client._http.request.andCall(httpReq); + + // set reasonable working defaults + acceptKeepalives = true; + pendingLookup = null; + httpLookups = []; + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push(FILTER_RESPONSE); + httpLookups.push(SYNC_RESPONSE); + }); + + afterEach(function() { + clock.uninstall(); + // need to re-stub the requests with NOPs because there are no guarantees + // clients from previous tests will be GC'd before the next test. This + // means they may call /events and then fail an expect() which will fail + // a DIFFERENT test (pollution between tests!) - we return unresolved + // promises to stop the client from continuing to run. + client._http.authedRequest.andCall(function() { + return Promise.defer().promise; + }); + client._http.authedRequestWithPrefix.andCall(function() { + return Promise.defer().promise; + }); + }); + + it("should not POST /filter if a matching filter already exists", async function() { + httpLookups = []; + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push(SYNC_RESPONSE); + const filterId = "ehfewf"; + store.getFilterIdByName.andReturn(filterId); + const filter = new sdk.Filter(0, filterId); + filter.setDefinition({"room": {"timeline": {"limit": 8}}}); + store.getFilter.andReturn(filter); + const syncPromise = new Promise((resolve, reject) => { + client.on("sync", function syncListener(state) { + if (state === "SYNCING") { + expect(httpLookups.length).toEqual(0); + client.removeListener("sync", syncListener); + resolve(); + } else if (state === "ERROR") { + reject(new Error("sync error")); + } + }); + }); + await client.startClient(); + await syncPromise; + }); + + describe("getSyncState", function() { + it("should return null if the client isn't started", function() { + expect(client.getSyncState()).toBe(null); + }); + + it("should return the same sync state as emitted sync events", async function() { + const syncingPromise = new Promise((resolve) => { + client.on("sync", function syncListener(state) { + expect(state).toEqual(client.getSyncState()); + if (state === "SYNCING") { + client.removeListener("sync", syncListener); + resolve(); + } + }); + }); + await client.startClient(); + await syncingPromise; + }); + }); + + describe("getOrCreateFilter", function() { + it("should POST createFilter if no id is present in localStorage", function() { + }); + it("should use an existing filter if id is present in localStorage", function() { + }); + it("should handle localStorage filterId missing from the server", function(done) { + 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 : ""); + } + const invalidFilterId = 'invalidF1lt3r'; + httpLookups = []; + httpLookups.push({ + method: "GET", + path: FILTER_PATH + '/' + invalidFilterId, + error: { + errcode: "M_UNKNOWN", + name: "M_UNKNOWN", + message: "No row found", + data: { errcode: "M_UNKNOWN", error: "No row found" }, + httpStatus: 404, + }, + }); + httpLookups.push(FILTER_RESPONSE); + store.getFilterIdByName.andReturn(invalidFilterId); + + const filterName = getFilterName(client.credentials.userId); + client.store.setFilterIdByName(filterName, invalidFilterId); + const filter = new sdk.Filter(client.credentials.userId); + + client.getOrCreateFilter(filterName, filter).then(function(filterId) { + expect(filterId).toEqual(FILTER_RESPONSE.data.filter_id); + done(); + }); + }); + }); + + describe("retryImmediately", function() { + it("should return false if there is no request waiting", async function() { + await client.startClient(); + expect(client.retryImmediately()).toBe(false); + }); + + it("should work on /filter", function(done) { + httpLookups = []; + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push({ + method: "POST", path: FILTER_PATH, error: { errcode: "NOPE_NOPE_NOPE" }, + }); + httpLookups.push(FILTER_RESPONSE); + httpLookups.push(SYNC_RESPONSE); + + client.on("sync", function syncListener(state) { + if (state === "ERROR" && httpLookups.length > 0) { + expect(httpLookups.length).toEqual(2); + expect(client.retryImmediately()).toBe(true); + clock.tick(1); + } else if (state === "PREPARED" && httpLookups.length === 0) { + client.removeListener("sync", syncListener); + done(); + } else { + // unexpected state transition! + expect(state).toEqual(null); + } + }); + client.startClient(); + }); + + it("should work on /sync", function(done) { + httpLookups.push({ + method: "GET", path: "/sync", error: { errcode: "NOPE_NOPE_NOPE" }, + }); + httpLookups.push({ + method: "GET", path: "/sync", data: SYNC_DATA, + }); + + client.on("sync", function syncListener(state) { + if (state === "ERROR" && httpLookups.length > 0) { + expect(httpLookups.length).toEqual(1); + expect(client.retryImmediately()).toBe( + true, "retryImmediately returned false", + ); + clock.tick(1); + } else if (state === "RECONNECTING" && httpLookups.length > 0) { + clock.tick(10000); + } else if (state === "SYNCING" && httpLookups.length === 0) { + client.removeListener("sync", syncListener); + done(); + } + }); + client.startClient(); + }); + + it("should work on /pushrules", function(done) { + httpLookups = []; + httpLookups.push({ + method: "GET", path: "/pushrules/", error: { errcode: "NOPE_NOPE_NOPE" }, + }); + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push(FILTER_RESPONSE); + httpLookups.push(SYNC_RESPONSE); + + client.on("sync", function syncListener(state) { + if (state === "ERROR" && httpLookups.length > 0) { + expect(httpLookups.length).toEqual(3); + expect(client.retryImmediately()).toBe(true); + clock.tick(1); + } else if (state === "PREPARED" && httpLookups.length === 0) { + client.removeListener("sync", syncListener); + done(); + } else { + // unexpected state transition! + expect(state).toEqual(null); + } + }); + client.startClient(); + }); + }); + + describe("emitted sync events", function() { + function syncChecker(expectedStates, done) { + return function syncListener(state, old) { + const expected = expectedStates.shift(); + logger.log( + "'sync' curr=%s old=%s EXPECT=%s", state, old, expected, + ); + if (!expected) { + done(); + return; + } + expect(state).toEqual(expected[0]); + expect(old).toEqual(expected[1]); + if (expectedStates.length === 0) { + client.removeListener("sync", syncListener); + done(); + } + // standard retry time is 5 to 10 seconds + clock.tick(10000); + }; + } + + it("should transition null -> PREPARED after the first /sync", function(done) { + const expectedStates = []; + expectedStates.push(["PREPARED", null]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); + + it("should transition null -> ERROR after a failed /filter", function(done) { + const expectedStates = []; + httpLookups = []; + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push({ + method: "POST", path: FILTER_PATH, error: { errcode: "NOPE_NOPE_NOPE" }, + }); + expectedStates.push(["ERROR", null]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); + + it("should transition ERROR -> CATCHUP after /sync if prev failed", + function(done) { + const expectedStates = []; + acceptKeepalives = false; + httpLookups = []; + httpLookups.push(PUSH_RULES_RESPONSE); + httpLookups.push(FILTER_RESPONSE); + httpLookups.push({ + method: "GET", path: "/sync", error: { errcode: "NOPE_NOPE_NOPE" }, + }); + httpLookups.push({ + method: "GET", path: KEEP_ALIVE_PATH, + error: { errcode: "KEEPALIVE_FAIL" }, + }); + httpLookups.push({ + method: "GET", path: KEEP_ALIVE_PATH, data: {}, + }); + httpLookups.push({ + method: "GET", path: "/sync", data: SYNC_DATA, + }); + + expectedStates.push(["RECONNECTING", null]); + expectedStates.push(["ERROR", "RECONNECTING"]); + expectedStates.push(["CATCHUP", "ERROR"]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); + + it("should transition PREPARED -> SYNCING after /sync", function(done) { + const expectedStates = []; + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); + + it("should transition SYNCING -> ERROR after a failed /sync", function(done) { + acceptKeepalives = false; + const expectedStates = []; + httpLookups.push({ + method: "GET", path: "/sync", error: { errcode: "NONONONONO" }, + }); + httpLookups.push({ + method: "GET", path: KEEP_ALIVE_PATH, + error: { errcode: "KEEPALIVE_FAIL" }, + }); + + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + expectedStates.push(["RECONNECTING", "SYNCING"]); + expectedStates.push(["ERROR", "RECONNECTING"]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); + + xit("should transition ERROR -> SYNCING after /sync if prev failed", + function(done) { + const expectedStates = []; + httpLookups.push({ + method: "GET", path: "/sync", error: { errcode: "NONONONONO" }, + }); + httpLookups.push(SYNC_RESPONSE); + + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + expectedStates.push(["ERROR", "SYNCING"]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); + + it("should transition SYNCING -> SYNCING on subsequent /sync successes", + function(done) { + const expectedStates = []; + httpLookups.push(SYNC_RESPONSE); + httpLookups.push(SYNC_RESPONSE); + + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + expectedStates.push(["SYNCING", "SYNCING"]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); + + it("should transition ERROR -> ERROR if keepalive keeps failing", function(done) { + acceptKeepalives = false; + const expectedStates = []; + httpLookups.push({ + method: "GET", path: "/sync", error: { errcode: "NONONONONO" }, + }); + httpLookups.push({ + method: "GET", path: KEEP_ALIVE_PATH, + error: { errcode: "KEEPALIVE_FAIL" }, + }); + httpLookups.push({ + method: "GET", path: KEEP_ALIVE_PATH, + error: { errcode: "KEEPALIVE_FAIL" }, + }); + + expectedStates.push(["PREPARED", null]); + expectedStates.push(["SYNCING", "PREPARED"]); + expectedStates.push(["RECONNECTING", "SYNCING"]); + expectedStates.push(["ERROR", "RECONNECTING"]); + expectedStates.push(["ERROR", "ERROR"]); + client.on("sync", syncChecker(expectedStates, done)); + client.startClient(); + }); + }); + + describe("inviteByEmail", function() { + const roomId = "!foo:bar"; + + it("should send an invite HTTP POST", function() { + httpLookups = [{ + method: "POST", + path: "/rooms/!foo%3Abar/invite", + data: {}, + expectBody: { + id_server: identityServerDomain, + medium: "email", + address: "alice@gmail.com", + }, + }]; + client.inviteByEmail(roomId, "alice@gmail.com"); + expect(httpLookups.length).toEqual(0); + }); + }); + + describe("guest rooms", function() { + it("should only do /sync calls (without filter/pushrules)", function(done) { + httpLookups = []; // no /pushrules or /filter + httpLookups.push({ + method: "GET", + path: "/sync", + data: SYNC_DATA, + thenCall: function() { + done(); + }, + }); + client.setGuest(true); + client.startClient(); + }); + + xit("should be able to peek into a room using peekInRoom", function(done) { + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/pushprocessor.spec.js b/matrix-js-sdk/spec/unit/pushprocessor.spec.js new file mode 100644 index 000000000..78d28473e --- /dev/null +++ b/matrix-js-sdk/spec/unit/pushprocessor.spec.js @@ -0,0 +1,309 @@ +"use strict"; +import 'source-map-support/register'; +const PushProcessor = require("../../lib/pushprocessor"); +const utils = require("../test-utils"); + +import expect from 'expect'; + +describe('NotificationService', function() { + const testUserId = "@ali:matrix.org"; + const testDisplayName = "Alice M"; + const testRoomId = "!fl1bb13:localhost"; + + let testEvent; + + let pushProcessor; + + // These would be better if individual rules were configured in the tests themselves. + const matrixClient = { + getRoom: function() { + return { + currentState: { + getMember: function() { + return { + name: testDisplayName, + }; + }, + getJoinedMemberCount: function() { + return 0; + }, + members: {}, + }, + }; + }, + credentials: { + userId: testUserId, + }, + pushRules: { + "device": {}, + "global": { + "content": [ + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default", + }, + { + "set_tweak": "highlight", + }, + ], + "enabled": true, + "pattern": "ali", + "rule_id": ".m.rule.contains_user_name", + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default", + }, + { + "set_tweak": "highlight", + }, + ], + "enabled": true, + "pattern": "coffee", + "rule_id": "coffee", + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default", + }, + { + "set_tweak": "highlight", + }, + ], + "enabled": true, + "pattern": "foo*bar", + "rule_id": "foobar", + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default", + }, + { + "set_tweak": "highlight", + }, + ], + "enabled": true, + "pattern": "p[io]ng", + "rule_id": "pingpong", + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default", + }, + { + "set_tweak": "highlight", + }, + ], + "enabled": true, + "pattern": "I ate [0-9] pies", + "rule_id": "pies", + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default", + }, + { + "set_tweak": "highlight", + }, + ], + "enabled": true, + "pattern": "b[!ai]ke", + "rule_id": "bakebike", + }, + ], + "override": [ + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default", + }, + { + "set_tweak": "highlight", + }, + ], + "conditions": [ + { + "kind": "contains_display_name", + }, + ], + "enabled": true, + "rule_id": ".m.rule.contains_display_name", + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default", + }, + ], + "conditions": [ + { + "is": "2", + "kind": "room_member_count", + }, + ], + "enabled": true, + "rule_id": ".m.rule.room_one_to_one", + }, + ], + "room": [], + "sender": [], + "underride": [ + { + "actions": [ + "dont-notify", + ], + "conditions": [ + { + "key": "content.msgtype", + "kind": "event_match", + "pattern": "m.notice", + }, + ], + "enabled": true, + "rule_id": ".m.rule.suppress_notices", + }, + { + "actions": [ + "notify", + { + "set_tweak": "highlight", + "value": false, + }, + ], + "conditions": [], + "enabled": true, + "rule_id": ".m.rule.fallback", + }, + ], + }, + }, + }; + + beforeEach(function() { + testEvent = utils.mkEvent({ + type: "m.room.message", + room: testRoomId, + user: "@alfred:localhost", + event: true, + content: { + body: "", + msgtype: "m.text", + }, + }); + pushProcessor = new PushProcessor(matrixClient); + }); + + // User IDs + + it('should bing on a user ID.', function() { + testEvent.event.content.body = "Hello @ali:matrix.org, how are you?"; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + it('should bing on a partial user ID with an @.', function() { + testEvent.event.content.body = "Hello @ali, how are you?"; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + it('should bing on a partial user ID without @.', function() { + testEvent.event.content.body = "Hello ali, how are you?"; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + it('should bing on a case-insensitive user ID.', function() { + testEvent.event.content.body = "Hello @AlI:matrix.org, how are you?"; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + // Display names + + it('should bing on a display name.', function() { + testEvent.event.content.body = "Hello Alice M, how are you?"; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + it('should bing on a case-insensitive display name.', function() { + testEvent.event.content.body = "Hello ALICE M, how are you?"; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + // Bing words + + it('should bing on a bing word.', function() { + testEvent.event.content.body = "I really like coffee"; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + it('should bing on case-insensitive bing words.', function() { + testEvent.event.content.body = "Coffee is great"; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + it('should bing on wildcard (.*) bing words.', function() { + testEvent.event.content.body = "It was foomahbar I think."; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + it('should bing on character group ([abc]) bing words.', function() { + testEvent.event.content.body = "Ping!"; + let actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + testEvent.event.content.body = "Pong!"; + actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + it('should bing on character range ([a-z]) bing words.', function() { + testEvent.event.content.body = "I ate 6 pies"; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + }); + + it('should bing on character negation ([!a]) bing words.', function() { + testEvent.event.content.body = "boke"; + let actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(true); + testEvent.event.content.body = "bake"; + actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(false); + }); + + // invalid + + it('should gracefully handle bad input.', function() { + testEvent.event.content.body = { "foo": "bar" }; + const actions = pushProcessor.actionsForEvent(testEvent); + expect(actions.tweaks.highlight).toEqual(false); + }); +}); diff --git a/matrix-js-sdk/spec/unit/realtime-callbacks.spec.js b/matrix-js-sdk/spec/unit/realtime-callbacks.spec.js new file mode 100644 index 000000000..c78cf82db --- /dev/null +++ b/matrix-js-sdk/spec/unit/realtime-callbacks.spec.js @@ -0,0 +1,184 @@ +"use strict"; + +import 'source-map-support/register'; +const callbacks = require("../../lib/realtime-callbacks"); +const testUtils = require("../test-utils.js"); + +import expect from 'expect'; +import lolex from 'lolex'; + +describe("realtime-callbacks", function() { + let clock; + + function tick(millis) { + clock.tick(millis); + } + + beforeEach(function() { + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + clock = lolex.install(); + const fakeDate = clock.Date; + callbacks.setNow(fakeDate.now.bind(fakeDate)); + }); + + afterEach(function() { + callbacks.setNow(); + clock.uninstall(); + }); + + describe("setTimeout", function() { + it("should call the callback after the timeout", function() { + const callback = expect.createSpy(); + callbacks.setTimeout(callback, 100); + + expect(callback).toNotHaveBeenCalled(); + tick(100); + expect(callback).toHaveBeenCalled(); + }); + + + it("should default to a zero timeout", function() { + const callback = expect.createSpy(); + callbacks.setTimeout(callback); + + expect(callback).toNotHaveBeenCalled(); + tick(0); + expect(callback).toHaveBeenCalled(); + }); + + it("should pass any parameters to the callback", function() { + const callback = expect.createSpy(); + callbacks.setTimeout(callback, 0, "a", "b", "c"); + tick(0); + expect(callback).toHaveBeenCalledWith("a", "b", "c"); + }); + + it("should set 'this' to the global object", function() { + let passed = false; + const callback = function() { + expect(this).toBe(global); // eslint-disable-line babel/no-invalid-this + expect(this.console).toBeTruthy(); // eslint-disable-line babel/no-invalid-this + passed = true; + }; + callbacks.setTimeout(callback); + tick(0); + expect(passed).toBe(true); + }); + + it("should handle timeouts of several seconds", function() { + const callback = expect.createSpy(); + callbacks.setTimeout(callback, 2000); + + expect(callback).toNotHaveBeenCalled(); + for (let i = 0; i < 4; i++) { + tick(500); + } + expect(callback).toHaveBeenCalled(); + }); + + it("should call multiple callbacks in the right order", function() { + const callback1 = expect.createSpy(); + const callback2 = expect.createSpy(); + const callback3 = expect.createSpy(); + callbacks.setTimeout(callback2, 200); + callbacks.setTimeout(callback1, 100); + callbacks.setTimeout(callback3, 300); + + expect(callback1).toNotHaveBeenCalled(); + expect(callback2).toNotHaveBeenCalled(); + expect(callback3).toNotHaveBeenCalled(); + tick(100); + expect(callback1).toHaveBeenCalled(); + expect(callback2).toNotHaveBeenCalled(); + expect(callback3).toNotHaveBeenCalled(); + tick(100); + expect(callback1).toHaveBeenCalled(); + expect(callback2).toHaveBeenCalled(); + expect(callback3).toNotHaveBeenCalled(); + tick(100); + expect(callback1).toHaveBeenCalled(); + expect(callback2).toHaveBeenCalled(); + expect(callback3).toHaveBeenCalled(); + }); + + it("should treat -ve timeouts the same as a zero timeout", function() { + const callback1 = expect.createSpy(); + const callback2 = expect.createSpy(); + + // check that cb1 is called before cb2 + callback1.andCall(function() { + expect(callback2).toNotHaveBeenCalled(); + }); + + callbacks.setTimeout(callback1); + callbacks.setTimeout(callback2, -100); + + expect(callback1).toNotHaveBeenCalled(); + expect(callback2).toNotHaveBeenCalled(); + tick(0); + expect(callback1).toHaveBeenCalled(); + expect(callback2).toHaveBeenCalled(); + }); + + it("should not get confused by chained calls", function() { + const callback2 = expect.createSpy(); + const callback1 = expect.createSpy(); + callback1.andCall(function() { + callbacks.setTimeout(callback2, 0); + expect(callback2).toNotHaveBeenCalled(); + }); + + callbacks.setTimeout(callback1); + expect(callback1).toNotHaveBeenCalled(); + expect(callback2).toNotHaveBeenCalled(); + tick(0); + expect(callback1).toHaveBeenCalled(); + // the fake timer won't actually run callbacks registered during + // one tick until the next tick. + tick(1); + expect(callback2).toHaveBeenCalled(); + }); + + it("should be immune to exceptions", function() { + const callback1 = expect.createSpy(); + callback1.andCall(function() { + throw new Error("prepare to die"); + }); + const callback2 = expect.createSpy(); + callbacks.setTimeout(callback1, 0); + callbacks.setTimeout(callback2, 0); + + expect(callback1).toNotHaveBeenCalled(); + expect(callback2).toNotHaveBeenCalled(); + tick(0); + expect(callback1).toHaveBeenCalled(); + expect(callback2).toHaveBeenCalled(); + }); + }); + + describe("cancelTimeout", function() { + it("should cancel a pending timeout", function() { + const callback = expect.createSpy(); + const k = callbacks.setTimeout(callback); + callbacks.clearTimeout(k); + tick(0); + expect(callback).toNotHaveBeenCalled(); + }); + + it("should not affect sooner timeouts", function() { + const callback1 = expect.createSpy(); + const callback2 = expect.createSpy(); + + callbacks.setTimeout(callback1, 100); + const k = callbacks.setTimeout(callback2, 200); + callbacks.clearTimeout(k); + + tick(100); + expect(callback1).toHaveBeenCalled(); + expect(callback2).toNotHaveBeenCalled(); + + tick(150); + expect(callback2).toNotHaveBeenCalled(); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/room-member.spec.js b/matrix-js-sdk/spec/unit/room-member.spec.js new file mode 100644 index 000000000..e07de6c96 --- /dev/null +++ b/matrix-js-sdk/spec/unit/room-member.spec.js @@ -0,0 +1,336 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const RoomMember = sdk.RoomMember; +const utils = require("../test-utils"); + +import expect from 'expect'; + +describe("RoomMember", function() { + const roomId = "!foo:bar"; + const userA = "@alice:bar"; + const userB = "@bertha:bar"; + const userC = "@clarissa:bar"; + let member; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + member = new RoomMember(roomId, userA); + }); + + describe("getAvatarUrl", function() { + const hsUrl = "https://my.home.server"; + + it("should return the URL from m.room.member preferentially", function() { + member.events.member = utils.mkEvent({ + event: true, + type: "m.room.member", + skey: userA, + room: roomId, + user: userA, + content: { + membership: "join", + avatar_url: "mxc://flibble/wibble", + }, + }); + const url = member.getAvatarUrl(hsUrl); + // we don't care about how the mxc->http conversion is done, other + // than it contains the mxc body. + expect(url.indexOf("flibble/wibble")).toNotEqual(-1); + }); + + it("should return an identicon HTTP URL if allowDefault was set and there " + + "was no m.room.member event", function() { + const url = member.getAvatarUrl(hsUrl, 64, 64, "crop", true); + expect(url.indexOf("http")).toEqual(0); // don't care about form + }); + + it("should return nothing if there is no m.room.member and allowDefault=false", + function() { + const url = member.getAvatarUrl(hsUrl, 64, 64, "crop", false); + expect(url).toEqual(null); + }); + }); + + describe("setPowerLevelEvent", function() { + it("should set 'powerLevel' and 'powerLevelNorm'.", function() { + const event = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@bertha:bar": 200, + "@invalid:user": 10, // shouldn't barf on this. + }, + }, + event: true, + }); + member.setPowerLevelEvent(event); + expect(member.powerLevel).toEqual(20); + expect(member.powerLevelNorm).toEqual(10); + + const memberB = new RoomMember(roomId, userB); + memberB.setPowerLevelEvent(event); + expect(memberB.powerLevel).toEqual(200); + expect(memberB.powerLevelNorm).toEqual(100); + }); + + it("should emit 'RoomMember.powerLevel' if the power level changes.", + function() { + const event = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@bertha:bar": 200, + "@invalid:user": 10, // shouldn't barf on this. + }, + }, + event: true, + }); + let emitCount = 0; + + member.on("RoomMember.powerLevel", function(emitEvent, emitMember) { + emitCount += 1; + expect(emitMember).toEqual(member); + expect(emitEvent).toEqual(event); + }); + + member.setPowerLevelEvent(event); + expect(emitCount).toEqual(1); + member.setPowerLevelEvent(event); // no-op + expect(emitCount).toEqual(1); + }); + + it("should honour power levels of zero.", + function() { + const event = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@alice:bar": 0, + }, + }, + event: true, + }); + let emitCount = 0; + + // set the power level to something other than zero or we + // won't get an event + member.powerLevel = 1; + member.on("RoomMember.powerLevel", function(emitEvent, emitMember) { + emitCount += 1; + expect(emitMember.userId).toEqual('@alice:bar'); + expect(emitMember.powerLevel).toEqual(0); + expect(emitEvent).toEqual(event); + }); + + member.setPowerLevelEvent(event); + expect(member.powerLevel).toEqual(0); + expect(emitCount).toEqual(1); + }); + }); + + describe("setTypingEvent", function() { + it("should set 'typing'", function() { + member.typing = false; + const memberB = new RoomMember(roomId, userB); + memberB.typing = true; + const memberC = new RoomMember(roomId, userC); + memberC.typing = true; + + const event = utils.mkEvent({ + type: "m.typing", + user: userA, + room: roomId, + content: { + user_ids: [ + userA, userC, + ], + }, + event: true, + }); + member.setTypingEvent(event); + memberB.setTypingEvent(event); + memberC.setTypingEvent(event); + + expect(member.typing).toEqual(true); + expect(memberB.typing).toEqual(false); + expect(memberC.typing).toEqual(true); + }); + + it("should emit 'RoomMember.typing' if the typing state changes", + function() { + const event = utils.mkEvent({ + type: "m.typing", + room: roomId, + content: { + user_ids: [ + userA, userC, + ], + }, + event: true, + }); + let emitCount = 0; + member.on("RoomMember.typing", function(ev, mem) { + expect(mem).toEqual(member); + expect(ev).toEqual(event); + emitCount += 1; + }); + member.typing = false; + member.setTypingEvent(event); + expect(emitCount).toEqual(1); + member.setTypingEvent(event); // no-op + expect(emitCount).toEqual(1); + }); + }); + + describe("isOutOfBand", function() { + it("should be set by markOutOfBand", function() { + const member = new RoomMember(); + expect(member.isOutOfBand()).toEqual(false); + member.markOutOfBand(); + expect(member.isOutOfBand()).toEqual(true); + }); + }); + + describe("setMembershipEvent", function() { + const joinEvent = utils.mkMembership({ + event: true, + mship: "join", + user: userA, + room: roomId, + name: "Alice", + }); + + const inviteEvent = utils.mkMembership({ + event: true, + mship: "invite", + user: userB, + skey: userA, + room: roomId, + }); + + it("should set 'membership' and assign the event to 'events.member'.", + function() { + member.setMembershipEvent(inviteEvent); + expect(member.membership).toEqual("invite"); + expect(member.events.member).toEqual(inviteEvent); + member.setMembershipEvent(joinEvent); + expect(member.membership).toEqual("join"); + expect(member.events.member).toEqual(joinEvent); + }); + + it("should set 'name' based on user_id, displayname and room state", + function() { + const roomState = { + getStateEvents: function(type) { + if (type !== "m.room.member") { + return []; + } + return [ + utils.mkMembership({ + event: true, mship: "join", room: roomId, + user: userB, + }), + utils.mkMembership({ + event: true, mship: "join", room: roomId, + user: userC, name: "Alice", + }), + joinEvent, + ]; + }, + getUserIdsWithDisplayName: function(displayName) { + return [userA, userC]; + }, + }; + expect(member.name).toEqual(userA); // default = user_id + member.setMembershipEvent(joinEvent); + expect(member.name).toEqual("Alice"); // prefer displayname + member.setMembershipEvent(joinEvent, roomState); + expect(member.name).toNotEqual("Alice"); // it should disambig. + // user_id should be there somewhere + expect(member.name.indexOf(userA)).toNotEqual(-1); + }); + + it("should emit 'RoomMember.membership' if the membership changes", function() { + let emitCount = 0; + member.on("RoomMember.membership", function(ev, mem) { + emitCount += 1; + expect(mem).toEqual(member); + expect(ev).toEqual(inviteEvent); + }); + member.setMembershipEvent(inviteEvent); + expect(emitCount).toEqual(1); + member.setMembershipEvent(inviteEvent); // no-op + expect(emitCount).toEqual(1); + }); + + it("should emit 'RoomMember.name' if the name changes", function() { + let emitCount = 0; + member.on("RoomMember.name", function(ev, mem) { + emitCount += 1; + expect(mem).toEqual(member); + expect(ev).toEqual(joinEvent); + }); + member.setMembershipEvent(joinEvent); + expect(emitCount).toEqual(1); + member.setMembershipEvent(joinEvent); // no-op + expect(emitCount).toEqual(1); + }); + + it("should set 'name' to user_id if it is just whitespace", function() { + const joinEvent = utils.mkMembership({ + event: true, + mship: "join", + user: userA, + room: roomId, + name: " \u200b ", + }); + + expect(member.name).toEqual(userA); // default = user_id + member.setMembershipEvent(joinEvent); + expect(member.name).toEqual(userA); // it should fallback because all whitespace + }); + + it("should disambiguate users on a fuzzy displayname match", function() { + const joinEvent = utils.mkMembership({ + event: true, + mship: "join", + user: userA, + room: roomId, + name: "Alíce\u200b", // note diacritic and zero width char + }); + + const roomState = { + getStateEvents: function(type) { + if (type !== "m.room.member") { + return []; + } + return [ + utils.mkMembership({ + event: true, mship: "join", room: roomId, + user: userC, name: "Alice", + }), + joinEvent, + ]; + }, + getUserIdsWithDisplayName: function(displayName) { + return [userA, userC]; + }, + }; + expect(member.name).toEqual(userA); // default = user_id + member.setMembershipEvent(joinEvent, roomState); + expect(member.name).toNotEqual("Alíce"); // it should disambig. + // user_id should be there somewhere + expect(member.name.indexOf(userA)).toNotEqual(-1); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/room-state.spec.js b/matrix-js-sdk/spec/unit/room-state.spec.js new file mode 100644 index 000000000..553f424ff --- /dev/null +++ b/matrix-js-sdk/spec/unit/room-state.spec.js @@ -0,0 +1,644 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const RoomState = sdk.RoomState; +const RoomMember = sdk.RoomMember; +const utils = require("../test-utils"); + +import expect from 'expect'; + +describe("RoomState", function() { + const roomId = "!foo:bar"; + const userA = "@alice:bar"; + const userB = "@bob:bar"; + const userC = "@cleo:bar"; + const userLazy = "@lazy:bar"; + + let state; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + state = new RoomState(roomId); + state.setStateEvents([ + utils.mkMembership({ // userA joined + event: true, mship: "join", user: userA, room: roomId, + }), + utils.mkMembership({ // userB joined + event: true, mship: "join", user: userB, room: roomId, + }), + utils.mkEvent({ // Room name is "Room name goes here" + type: "m.room.name", user: userA, room: roomId, event: true, content: { + name: "Room name goes here", + }, + }), + utils.mkEvent({ // Room creation + type: "m.room.create", user: userA, room: roomId, event: true, content: { + creator: userA, + }, + }), + ]); + }); + + describe("getMembers", function() { + it("should return an empty list if there are no members", function() { + state = new RoomState(roomId); + expect(state.getMembers().length).toEqual(0); + }); + + it("should return a member for each m.room.member event", function() { + const members = state.getMembers(); + expect(members.length).toEqual(2); + // ordering unimportant + expect([userA, userB].indexOf(members[0].userId)).toNotEqual(-1); + expect([userA, userB].indexOf(members[1].userId)).toNotEqual(-1); + }); + }); + + describe("getMember", function() { + it("should return null if there is no member", function() { + expect(state.getMember("@no-one:here")).toEqual(null); + }); + + it("should return a member if they exist", function() { + expect(state.getMember(userB)).toBeTruthy(); + }); + + it("should return a member which changes as state changes", function() { + const member = state.getMember(userB); + expect(member.membership).toEqual("join"); + expect(member.name).toEqual(userB); + + state.setStateEvents([ + utils.mkMembership({ + room: roomId, user: userB, mship: "leave", event: true, + name: "BobGone", + }), + ]); + + expect(member.membership).toEqual("leave"); + expect(member.name).toEqual("BobGone"); + }); + }); + + describe("getSentinelMember", function() { + it("should return a member with the user id as name", function() { + expect(state.getSentinelMember("@no-one:here").name).toEqual("@no-one:here"); + }); + + it("should return a member which doesn't change when the state is updated", + function() { + const preLeaveUser = state.getSentinelMember(userA); + state.setStateEvents([ + utils.mkMembership({ + room: roomId, user: userA, mship: "leave", event: true, + name: "AliceIsGone", + }), + ]); + const postLeaveUser = state.getSentinelMember(userA); + + expect(preLeaveUser.membership).toEqual("join"); + expect(preLeaveUser.name).toEqual(userA); + + expect(postLeaveUser.membership).toEqual("leave"); + expect(postLeaveUser.name).toEqual("AliceIsGone"); + }); + }); + + describe("getStateEvents", function() { + it("should return null if a state_key was specified and there was no match", + function() { + expect(state.getStateEvents("foo.bar.baz", "keyname")).toEqual(null); + }); + + it("should return an empty list if a state_key was not specified and there" + + " was no match", function() { + expect(state.getStateEvents("foo.bar.baz")).toEqual([]); + }); + + it("should return a list of matching events if no state_key was specified", + function() { + const events = state.getStateEvents("m.room.member"); + expect(events.length).toEqual(2); + // ordering unimportant + expect([userA, userB].indexOf(events[0].getStateKey())).toNotEqual(-1); + expect([userA, userB].indexOf(events[1].getStateKey())).toNotEqual(-1); + }); + + it("should return a single MatrixEvent if a state_key was specified", + function() { + const event = state.getStateEvents("m.room.member", userA); + expect(event.getContent()).toEqual({ + membership: "join", + }); + }); + }); + + describe("setStateEvents", function() { + it("should emit 'RoomState.members' for each m.room.member event", function() { + const memberEvents = [ + utils.mkMembership({ + user: "@cleo:bar", mship: "invite", room: roomId, event: true, + }), + utils.mkMembership({ + user: "@daisy:bar", mship: "join", room: roomId, event: true, + }), + ]; + let emitCount = 0; + state.on("RoomState.members", function(ev, st, mem) { + expect(ev).toEqual(memberEvents[emitCount]); + expect(st).toEqual(state); + expect(mem).toEqual(state.getMember(ev.getSender())); + emitCount += 1; + }); + state.setStateEvents(memberEvents); + expect(emitCount).toEqual(2); + }); + + it("should emit 'RoomState.newMember' for each new member added", function() { + const memberEvents = [ + utils.mkMembership({ + user: "@cleo:bar", mship: "invite", room: roomId, event: true, + }), + utils.mkMembership({ + user: "@daisy:bar", mship: "join", room: roomId, event: true, + }), + ]; + let emitCount = 0; + state.on("RoomState.newMember", function(ev, st, mem) { + expect(state.getMember(mem.userId)).toEqual(mem); + expect(mem.userId).toEqual(memberEvents[emitCount].getSender()); + expect(mem.membership).toBeFalsy(); // not defined yet + emitCount += 1; + }); + state.setStateEvents(memberEvents); + expect(emitCount).toEqual(2); + }); + + it("should emit 'RoomState.events' for each state event", function() { + const events = [ + utils.mkMembership({ + user: "@cleo:bar", mship: "invite", room: roomId, event: true, + }), + utils.mkEvent({ + user: userB, room: roomId, type: "m.room.topic", event: true, + content: { + topic: "boo!", + }, + }), + utils.mkMessage({ // Not a state event + user: userA, room: roomId, event: true, + }), + ]; + let emitCount = 0; + state.on("RoomState.events", function(ev, st) { + expect(ev).toEqual(events[emitCount]); + expect(st).toEqual(state); + emitCount += 1; + }); + state.setStateEvents(events); + 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); + + const powerLevelEvent = utils.mkEvent({ + type: "m.room.power_levels", room: roomId, user: userA, event: true, + content: { + users_default: 10, + state_default: 50, + events_default: 25, + }, + }); + + state.setStateEvents([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() { + const memberEvent = utils.mkMembership({ + mship: "join", user: userC, room: roomId, event: true, + }); + const powerLevelEvent = utils.mkEvent({ + type: "m.room.power_levels", room: roomId, user: userA, event: true, + content: { + users_default: 10, + state_default: 50, + events_default: 25, + users: {}, + }, + }); + + state.setStateEvents([powerLevelEvent]); + state.setStateEvents([memberEvent]); + + // TODO: We do this because we don't DI the RoomMember constructor + // so we can't inject a mock :/ so we have to infer. + expect(state.members[userC]).toBeTruthy(); + expect(state.members[userC].powerLevel).toEqual(10); + }); + + 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, + }); + state.setStateEvents([memberEvent]); + + expect(state.members[userA].setMembershipEvent).toNotHaveBeenCalled(); + expect(state.members[userB].setMembershipEvent).toHaveBeenCalledWith( + memberEvent, state, + ); + }); + }); + + describe("setOutOfBandMembers", function() { + it("should add a new member", function() { + const oobMemberEvent = utils.mkMembership({ + user: userLazy, mship: "join", room: roomId, event: true, + }); + state.markOutOfBandMembersStarted(); + state.setOutOfBandMembers([oobMemberEvent]); + const member = state.getMember(userLazy); + expect(member.userId).toEqual(userLazy); + expect(member.isOutOfBand()).toEqual(true); + }); + + it("should have no effect when not in correct status", function() { + state.setOutOfBandMembers([utils.mkMembership({ + user: userLazy, mship: "join", room: roomId, event: true, + })]); + expect(state.getMember(userLazy)).toBeFalsy(); + }); + + it("should emit newMember when adding a member", function() { + const userLazy = "@oob:hs"; + const oobMemberEvent = utils.mkMembership({ + user: userLazy, mship: "join", room: roomId, event: true, + }); + let eventReceived = false; + state.once('RoomState.newMember', (_, __, member) => { + expect(member.userId).toEqual(userLazy); + eventReceived = true; + }); + state.markOutOfBandMembersStarted(); + state.setOutOfBandMembers([oobMemberEvent]); + expect(eventReceived).toEqual(true); + }); + + it("should never overwrite existing members", function() { + const oobMemberEvent = utils.mkMembership({ + user: userA, mship: "join", room: roomId, event: true, + }); + state.markOutOfBandMembersStarted(); + state.setOutOfBandMembers([oobMemberEvent]); + const memberA = state.getMember(userA); + expect(memberA.events.member.getId()).toNotEqual(oobMemberEvent.getId()); + expect(memberA.isOutOfBand()).toEqual(false); + }); + + it("should emit members when updating a member", function() { + const doesntExistYetUserId = "@doesntexistyet:hs"; + const oobMemberEvent = utils.mkMembership({ + user: doesntExistYetUserId, mship: "join", room: roomId, event: true, + }); + let eventReceived = false; + state.once('RoomState.members', (_, __, member) => { + expect(member.userId).toEqual(doesntExistYetUserId); + eventReceived = true; + }); + + state.markOutOfBandMembersStarted(); + state.setOutOfBandMembers([oobMemberEvent]); + expect(eventReceived).toEqual(true); + }); + }); + + describe("clone", function() { + it("should contain same information as original", function() { + // include OOB members in copy + state.markOutOfBandMembersStarted(); + state.setOutOfBandMembers([utils.mkMembership({ + user: userLazy, mship: "join", room: roomId, event: true, + })]); + const copy = state.clone(); + // check individual members + [userA, userB, userLazy].forEach((userId) => { + const member = state.getMember(userId); + const memberCopy = copy.getMember(userId); + expect(member.name).toEqual(memberCopy.name); + expect(member.isOutOfBand()).toEqual(memberCopy.isOutOfBand()); + }); + // check member keys + expect(Object.keys(state.members)).toEqual(Object.keys(copy.members)); + // check join count + expect(state.getJoinedMemberCount()).toEqual(copy.getJoinedMemberCount()); + }); + + it("should mark old copy as not waiting for out of band anymore", function() { + state.markOutOfBandMembersStarted(); + const copy = state.clone(); + copy.setOutOfBandMembers([utils.mkMembership({ + user: userA, mship: "join", room: roomId, event: true, + })]); + // should have no effect as it should be marked in status finished just like copy + state.setOutOfBandMembers([utils.mkMembership({ + user: userLazy, mship: "join", room: roomId, event: true, + })]); + expect(state.getMember(userLazy)).toBeFalsy(); + }); + + it("should return copy independent of original", function() { + const copy = state.clone(); + copy.setStateEvents([utils.mkMembership({ + user: userLazy, mship: "join", room: roomId, event: true, + })]); + + expect(state.getMember(userLazy)).toBeFalsy(); + expect(state.getJoinedMemberCount()).toEqual(2); + expect(copy.getJoinedMemberCount()).toEqual(3); + }); + }); + + describe("setTypingEvent", function() { + it("should call setTypingEvent on each RoomMember", function() { + const typingEvent = utils.mkEvent({ + type: "m.typing", room: roomId, event: true, content: { + user_ids: [userA], + }, + }); + // mock up the room members + state.members[userA] = utils.mock(RoomMember); + state.members[userB] = utils.mock(RoomMember); + state.setTypingEvent(typingEvent); + + expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith( + typingEvent, + ); + expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith( + typingEvent, + ); + }); + }); + + describe("maySendStateEvent", function() { + it("should say any member may send state with no power level event", + function() { + expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); + }); + + it("should say members with power >=50 may send state with power level event " + + "but no state default", + function() { + const powerLevelEvent = { + type: "m.room.power_levels", room: roomId, user: userA, event: true, + content: { + users_default: 10, + // state_default: 50, "intentionally left blank" + events_default: 25, + users: { + }, + }, + }; + powerLevelEvent.content.users[userA] = 50; + + state.setStateEvents([utils.mkEvent(powerLevelEvent)]); + + expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); + expect(state.maySendStateEvent('m.room.name', userB)).toEqual(false); + }); + + it("should obey state_default", + function() { + const powerLevelEvent = { + type: "m.room.power_levels", room: roomId, user: userA, event: true, + content: { + users_default: 10, + state_default: 30, + events_default: 25, + users: { + }, + }, + }; + powerLevelEvent.content.users[userA] = 30; + powerLevelEvent.content.users[userB] = 29; + + state.setStateEvents([utils.mkEvent(powerLevelEvent)]); + + expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); + expect(state.maySendStateEvent('m.room.name', userB)).toEqual(false); + }); + + it("should honour explicit event power levels in the power_levels event", + function() { + const powerLevelEvent = { + type: "m.room.power_levels", room: roomId, user: userA, event: true, + content: { + events: { + "m.room.other_thing": 76, + }, + users_default: 10, + state_default: 50, + events_default: 25, + users: { + }, + }, + }; + powerLevelEvent.content.users[userA] = 80; + powerLevelEvent.content.users[userB] = 50; + + state.setStateEvents([utils.mkEvent(powerLevelEvent)]); + + expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); + expect(state.maySendStateEvent('m.room.name', userB)).toEqual(true); + + expect(state.maySendStateEvent('m.room.other_thing', userA)).toEqual(true); + expect(state.maySendStateEvent('m.room.other_thing', userB)).toEqual(false); + }); + }); + + describe("getJoinedMemberCount", function() { + beforeEach(() => { + state = new RoomState(roomId); + }); + + it("should update after adding joined member", function() { + state.setStateEvents([ + utils.mkMembership({event: true, mship: "join", + user: userA, room: roomId}), + ]); + expect(state.getJoinedMemberCount()).toEqual(1); + state.setStateEvents([ + utils.mkMembership({event: true, mship: "join", + user: userC, room: roomId}), + ]); + expect(state.getJoinedMemberCount()).toEqual(2); + }); + }); + + describe("getInvitedMemberCount", function() { + beforeEach(() => { + state = new RoomState(roomId); + }); + + it("should update after adding invited member", function() { + state.setStateEvents([ + utils.mkMembership({event: true, mship: "invite", + user: userA, room: roomId}), + ]); + expect(state.getInvitedMemberCount()).toEqual(1); + state.setStateEvents([ + utils.mkMembership({event: true, mship: "invite", + user: userC, room: roomId}), + ]); + expect(state.getInvitedMemberCount()).toEqual(2); + }); + }); + + describe("setJoinedMemberCount", function() { + beforeEach(() => { + state = new RoomState(roomId); + }); + + it("should, once used, override counting members from state", function() { + state.setStateEvents([ + utils.mkMembership({event: true, mship: "join", + user: userA, room: roomId}), + ]); + expect(state.getJoinedMemberCount()).toEqual(1); + state.setJoinedMemberCount(100); + expect(state.getJoinedMemberCount()).toEqual(100); + state.setStateEvents([ + utils.mkMembership({event: true, mship: "join", + user: userC, room: roomId}), + ]); + expect(state.getJoinedMemberCount()).toEqual(100); + }); + + it("should, once used, override counting members from state, " + + "also after clone", function() { + state.setStateEvents([ + utils.mkMembership({event: true, mship: "join", + user: userA, room: roomId}), + ]); + state.setJoinedMemberCount(100); + const copy = state.clone(); + copy.setStateEvents([ + utils.mkMembership({event: true, mship: "join", + user: userC, room: roomId}), + ]); + expect(state.getJoinedMemberCount()).toEqual(100); + }); + }); + + describe("setInvitedMemberCount", function() { + beforeEach(() => { + state = new RoomState(roomId); + }); + + it("should, once used, override counting members from state", function() { + state.setStateEvents([ + utils.mkMembership({event: true, mship: "invite", + user: userB, room: roomId}), + ]); + expect(state.getInvitedMemberCount()).toEqual(1); + state.setInvitedMemberCount(100); + expect(state.getInvitedMemberCount()).toEqual(100); + state.setStateEvents([ + utils.mkMembership({event: true, mship: "invite", + user: userC, room: roomId}), + ]); + expect(state.getInvitedMemberCount()).toEqual(100); + }); + + it("should, once used, override counting members from state, " + + "also after clone", function() { + state.setStateEvents([ + utils.mkMembership({event: true, mship: "invite", + user: userB, room: roomId}), + ]); + state.setInvitedMemberCount(100); + const copy = state.clone(); + copy.setStateEvents([ + utils.mkMembership({event: true, mship: "invite", + user: userC, room: roomId}), + ]); + expect(state.getInvitedMemberCount()).toEqual(100); + }); + }); + + describe("maySendEvent", function() { + it("should say any member may send events with no power level event", + function() { + expect(state.maySendEvent('m.room.message', userA)).toEqual(true); + expect(state.maySendMessage(userA)).toEqual(true); + }); + + it("should obey events_default", + function() { + const powerLevelEvent = { + type: "m.room.power_levels", room: roomId, user: userA, event: true, + content: { + users_default: 10, + state_default: 30, + events_default: 25, + users: { + }, + }, + }; + powerLevelEvent.content.users[userA] = 26; + powerLevelEvent.content.users[userB] = 24; + + state.setStateEvents([utils.mkEvent(powerLevelEvent)]); + + expect(state.maySendEvent('m.room.message', userA)).toEqual(true); + expect(state.maySendEvent('m.room.message', userB)).toEqual(false); + + expect(state.maySendMessage(userA)).toEqual(true); + expect(state.maySendMessage(userB)).toEqual(false); + }); + + it("should honour explicit event power levels in the power_levels event", + function() { + const powerLevelEvent = { + type: "m.room.power_levels", room: roomId, user: userA, event: true, + content: { + events: { + "m.room.other_thing": 33, + }, + users_default: 10, + state_default: 50, + events_default: 25, + users: { + }, + }, + }; + powerLevelEvent.content.users[userA] = 40; + powerLevelEvent.content.users[userB] = 30; + + state.setStateEvents([utils.mkEvent(powerLevelEvent)]); + + expect(state.maySendEvent('m.room.message', userA)).toEqual(true); + expect(state.maySendEvent('m.room.message', userB)).toEqual(true); + + expect(state.maySendMessage(userA)).toEqual(true); + expect(state.maySendMessage(userB)).toEqual(true); + + expect(state.maySendEvent('m.room.other_thing', userA)).toEqual(true); + expect(state.maySendEvent('m.room.other_thing', userB)).toEqual(false); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/room.spec.js b/matrix-js-sdk/spec/unit/room.spec.js new file mode 100644 index 000000000..b257ec637 --- /dev/null +++ b/matrix-js-sdk/spec/unit/room.spec.js @@ -0,0 +1,1465 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const Room = sdk.Room; +const RoomState = sdk.RoomState; +const MatrixEvent = sdk.MatrixEvent; +const EventStatus = sdk.EventStatus; +const EventTimeline = sdk.EventTimeline; +const utils = require("../test-utils"); + +import expect from 'expect'; + +describe("Room", function() { + const roomId = "!foo:bar"; + const userA = "@alice:bar"; + const userB = "@bertha:bar"; + const userC = "@clarissa:bar"; + const userD = "@dorothy:bar"; + let room; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + room = new Room(roomId); + // mock RoomStates + room.oldState = room.getLiveTimeline()._startState = + utils.mock(sdk.RoomState, "oldState"); + room.currentState = room.getLiveTimeline()._endState = + utils.mock(sdk.RoomState, "currentState"); + }); + + describe("getAvatarUrl", function() { + const hsUrl = "https://my.home.server"; + + it("should return the URL from m.room.avatar preferentially", function() { + room.currentState.getStateEvents.andCall(function(type, key) { + if (type === "m.room.avatar" && key === "") { + return utils.mkEvent({ + event: true, + type: "m.room.avatar", + skey: "", + room: roomId, + user: userA, + content: { + url: "mxc://flibble/wibble", + }, + }); + } + }); + const url = room.getAvatarUrl(hsUrl); + // we don't care about how the mxc->http conversion is done, other + // than it contains the mxc body. + expect(url.indexOf("flibble/wibble")).toNotEqual(-1); + }); + + it("should return an identicon HTTP URL if allowDefault was set and there " + + "was no m.room.avatar event", function() { + const url = room.getAvatarUrl(hsUrl, 64, 64, "crop", true); + expect(url.indexOf("http")).toEqual(0); // don't care about form + }); + + it("should return nothing if there is no m.room.avatar and allowDefault=false", + function() { + const url = room.getAvatarUrl(hsUrl, 64, 64, "crop", false); + expect(url).toEqual(null); + }); + }); + + describe("getMember", function() { + beforeEach(function() { + room.currentState.getMember.andCall(function(userId) { + return { + "@alice:bar": { + userId: userA, + roomId: roomId, + }, + }[userId]; + }); + }); + + it("should return null if the member isn't in current state", function() { + expect(room.getMember("@bar:foo")).toEqual(null); + }); + + it("should return the member from current state", function() { + expect(room.getMember(userA)).toNotEqual(null); + }); + }); + + describe("addLiveEvents", function() { + const events = [ + utils.mkMessage({ + room: roomId, user: userA, msg: "changing room name", event: true, + }), + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userA, event: true, + content: { name: "New Room Name" }, + }), + ]; + + 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: { + user_ids: [userA], + }, + }); + room.addEphemeralEvents([typing]); + expect(room.currentState.setTypingEvent).toHaveBeenCalledWith(typing); + }); + + it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function() { + expect(function() { + room.addLiveEvents(events, "foo"); + }).toThrow(); + }); + + it("should replace a timeline event if dupe strategy is 'replace'", function() { + // make a duplicate + const dupe = utils.mkMessage({ + room: roomId, user: userA, msg: "dupe", event: true, + }); + dupe.event.event_id = events[0].getId(); + room.addLiveEvents(events); + expect(room.timeline[0]).toEqual(events[0]); + room.addLiveEvents([dupe], "replace"); + expect(room.timeline[0]).toEqual(dupe); + }); + + it("should ignore a given dupe event if dupe strategy is 'ignore'", function() { + // make a duplicate + const dupe = utils.mkMessage({ + room: roomId, user: userA, msg: "dupe", event: true, + }); + dupe.event.event_id = events[0].getId(); + room.addLiveEvents(events); + expect(room.timeline[0]).toEqual(events[0]); + room.addLiveEvents([dupe], "ignore"); + expect(room.timeline[0]).toEqual(events[0]); + }); + + it("should emit 'Room.timeline' events", + function() { + let callCount = 0; + room.on("Room.timeline", function(event, emitRoom, toStart) { + callCount += 1; + expect(room.timeline.length).toEqual(callCount); + expect(event).toEqual(events[callCount - 1]); + expect(emitRoom).toEqual(room); + expect(toStart).toBeFalsy(); + }); + room.addLiveEvents(events); + expect(callCount).toEqual(2); + }); + + 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, + }), + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userB, event: true, + content: { + name: "New room", + }, + }), + ]; + room.addLiveEvents(events); + expect(room.currentState.setStateEvents).toHaveBeenCalledWith( + [events[0]], + ); + expect(room.currentState.setStateEvents).toHaveBeenCalledWith( + [events[1]], + ); + expect(events[0].forwardLooking).toBe(true); + expect(events[1].forwardLooking).toBe(true); + expect(room.oldState.setStateEvents).toNotHaveBeenCalled(); + }); + + it("should synthesize read receipts for the senders of events", function() { + const sentinel = { + userId: userA, + membership: "join", + name: "Alice", + }; + room.currentState.getSentinelMember.andCall(function(uid) { + if (uid === userA) { + return sentinel; + } + return null; + }); + room.addLiveEvents(events); + expect(room.getEventReadUpTo(userA)).toEqual(events[1].getId()); + }); + + it("should emit Room.localEchoUpdated when a local echo is updated", function() { + const localEvent = utils.mkMessage({ + room: roomId, user: userA, event: true, + }); + localEvent.status = EventStatus.SENDING; + const localEventId = localEvent.getId(); + + const remoteEvent = utils.mkMessage({ + room: roomId, user: userA, event: true, + }); + remoteEvent.event.unsigned = {transaction_id: "TXN_ID"}; + const remoteEventId = remoteEvent.getId(); + + let callCount = 0; + room.on("Room.localEchoUpdated", + function(event, emitRoom, oldEventId, oldStatus) { + switch (callCount) { + case 0: + expect(event.getId()).toEqual(localEventId); + expect(event.status).toEqual(EventStatus.SENDING); + expect(emitRoom).toEqual(room); + expect(oldEventId).toBe(null); + expect(oldStatus).toBe(null); + break; + case 1: + expect(event.getId()).toEqual(remoteEventId); + expect(event.status).toBe(null); + expect(emitRoom).toEqual(room); + expect(oldEventId).toEqual(localEventId); + expect(oldStatus).toBe(EventStatus.SENDING); + break; + } + callCount += 1; + }, + ); + + // first add the local echo + room.addPendingEvent(localEvent, "TXN_ID"); + expect(room.timeline.length).toEqual(1); + + // then the remoteEvent + room.addLiveEvents([remoteEvent]); + expect(room.timeline.length).toEqual(1); + + expect(callCount).toEqual(2); + }); + }); + + describe("addEventsToTimeline", function() { + const events = [ + utils.mkMessage({ + room: roomId, user: userA, msg: "changing room name", event: true, + }), + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userA, event: true, + content: { name: "New Room Name" }, + }), + ]; + + it("should not be able to add events to the end", function() { + expect(function() { + room.addEventsToTimeline(events, false, room.getLiveTimeline()); + }).toThrow(); + }); + + it("should be able to add events to the start", function() { + room.addEventsToTimeline(events, true, room.getLiveTimeline()); + expect(room.timeline.length).toEqual(2); + expect(room.timeline[0]).toEqual(events[1]); + expect(room.timeline[1]).toEqual(events[0]); + }); + + it("should emit 'Room.timeline' events when added to the start", + function() { + let callCount = 0; + room.on("Room.timeline", function(event, emitRoom, toStart) { + callCount += 1; + expect(room.timeline.length).toEqual(callCount); + expect(event).toEqual(events[callCount - 1]); + expect(emitRoom).toEqual(room); + expect(toStart).toBe(true); + }); + room.addEventsToTimeline(events, true, room.getLiveTimeline()); + expect(callCount).toEqual(2); + }); + }); + + describe("event metadata handling", function() { + it("should set event.sender for new and old events", function() { + const sentinel = { + userId: userA, + membership: "join", + name: "Alice", + }; + const oldSentinel = { + userId: userA, + membership: "join", + name: "Old Alice", + }; + room.currentState.getSentinelMember.andCall(function(uid) { + if (uid === userA) { + return sentinel; + } + return null; + }); + room.oldState.getSentinelMember.andCall(function(uid) { + if (uid === userA) { + return oldSentinel; + } + return null; + }); + + const newEv = utils.mkEvent({ + type: "m.room.name", room: roomId, user: userA, event: true, + content: { name: "New Room Name" }, + }); + const oldEv = utils.mkEvent({ + type: "m.room.name", room: roomId, user: userA, event: true, + content: { name: "Old Room Name" }, + }); + room.addLiveEvents([newEv]); + expect(newEv.sender).toEqual(sentinel); + room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); + expect(oldEv.sender).toEqual(oldSentinel); + }); + + it("should set event.target for new and old m.room.member events", + function() { + const sentinel = { + userId: userA, + membership: "join", + name: "Alice", + }; + const oldSentinel = { + userId: userA, + membership: "join", + name: "Old Alice", + }; + room.currentState.getSentinelMember.andCall(function(uid) { + if (uid === userA) { + return sentinel; + } + return null; + }); + room.oldState.getSentinelMember.andCall(function(uid) { + if (uid === userA) { + return oldSentinel; + } + return null; + }); + + const newEv = utils.mkMembership({ + room: roomId, mship: "invite", user: userB, skey: userA, event: true, + }); + const oldEv = utils.mkMembership({ + room: roomId, mship: "ban", user: userB, skey: userA, event: true, + }); + room.addLiveEvents([newEv]); + expect(newEv.target).toEqual(sentinel); + room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); + expect(oldEv.target).toEqual(oldSentinel); + }); + + it("should call setStateEvents on the right RoomState with the right " + + "forwardLooking value for old events", function() { + const events = [ + utils.mkMembership({ + room: roomId, mship: "invite", user: userB, skey: userA, event: true, + }), + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userB, event: true, + content: { + name: "New room", + }, + }), + ]; + + room.addEventsToTimeline(events, true, room.getLiveTimeline()); + expect(room.oldState.setStateEvents).toHaveBeenCalledWith( + [events[0]], + ); + expect(room.oldState.setStateEvents).toHaveBeenCalledWith( + [events[1]], + ); + expect(events[0].forwardLooking).toBe(false); + expect(events[1].forwardLooking).toBe(false); + expect(room.currentState.setStateEvents).toNotHaveBeenCalled(); + }); + }); + + const resetTimelineTests = function(timelineSupport) { + let events = null; + + beforeEach(function() { + room = new Room(roomId, null, null, {timelineSupport: timelineSupport}); + // set events each time to avoid resusing Event objects (which + // doesn't work because they get frozen) + events = [ + utils.mkMessage({ + room: roomId, user: userA, msg: "A message", event: true, + }), + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userA, event: true, + content: { name: "New Room Name" }, + }), + utils.mkEvent({ + type: "m.room.name", room: roomId, user: userA, event: true, + content: { name: "Another New Name" }, + }), + ]; + }); + + it("should copy state from previous timeline", function() { + room.addLiveEvents([events[0], events[1]]); + expect(room.getLiveTimeline().getEvents().length).toEqual(2); + room.resetLiveTimeline('sometoken', 'someothertoken'); + + room.addLiveEvents([events[2]]); + const oldState = room.getLiveTimeline().getState(EventTimeline.BACKWARDS); + const newState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); + expect(room.getLiveTimeline().getEvents().length).toEqual(1); + expect(oldState.getStateEvents("m.room.name", "")).toEqual(events[1]); + expect(newState.getStateEvents("m.room.name", "")).toEqual(events[2]); + }); + + it("should reset the legacy timeline fields", function() { + room.addLiveEvents([events[0], events[1]]); + expect(room.timeline.length).toEqual(2); + room.resetLiveTimeline('sometoken', 'someothertoken'); + + room.addLiveEvents([events[2]]); + const newLiveTimeline = room.getLiveTimeline(); + expect(room.timeline).toEqual(newLiveTimeline.getEvents()); + expect(room.oldState).toEqual( + newLiveTimeline.getState(EventTimeline.BACKWARDS)); + expect(room.currentState).toEqual( + newLiveTimeline.getState(EventTimeline.FORWARDS)); + }); + + it("should emit Room.timelineReset event and set the correct " + + "pagination token", function() { + let callCount = 0; + room.on("Room.timelineReset", function(emitRoom) { + callCount += 1; + expect(emitRoom).toEqual(room); + + // make sure that the pagination token has been set before the + // event is emitted. + const tok = emitRoom.getLiveTimeline() + .getPaginationToken(EventTimeline.BACKWARDS); + + expect(tok).toEqual("pagToken"); + }); + room.resetLiveTimeline("pagToken"); + expect(callCount).toEqual(1); + }); + + it("should " + (timelineSupport ? "remember" : "forget") + + " old timelines", function() { + room.addLiveEvents([events[0]]); + expect(room.timeline.length).toEqual(1); + const firstLiveTimeline = room.getLiveTimeline(); + room.resetLiveTimeline('sometoken', 'someothertoken'); + + const tl = room.getTimelineForEvent(events[0].getId()); + expect(tl).toBe(timelineSupport ? firstLiveTimeline : null); + }); + }; + + describe("resetLiveTimeline with timelinesupport enabled", + resetTimelineTests.bind(null, true)); + describe("resetLiveTimeline with timelinesupport disabled", + resetTimelineTests.bind(null, false)); + + describe("compareEventOrdering", function() { + beforeEach(function() { + room = new Room(roomId, null, null, {timelineSupport: true}); + }); + + const events = [ + utils.mkMessage({ + room: roomId, user: userA, msg: "1111", event: true, + }), + utils.mkMessage({ + room: roomId, user: userA, msg: "2222", event: true, + }), + utils.mkMessage({ + room: roomId, user: userA, msg: "3333", event: true, + }), + ]; + + it("should handle events in the same timeline", function() { + room.addLiveEvents(events); + + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), + events[1].getId())) + .toBeLessThan(0); + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[2].getId(), + events[1].getId())) + .toBeGreaterThan(0); + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), + events[1].getId())) + .toEqual(0); + }); + + it("should handle events in adjacent timelines", function() { + const oldTimeline = room.addTimeline(); + oldTimeline.setNeighbouringTimeline(room.getLiveTimeline(), 'f'); + room.getLiveTimeline().setNeighbouringTimeline(oldTimeline, 'b'); + + room.addEventsToTimeline([events[0]], false, oldTimeline); + room.addLiveEvents([events[1]]); + + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), + events[1].getId())) + .toBeLessThan(0); + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), + events[0].getId())) + .toBeGreaterThan(0); + }); + + it("should return null for events in non-adjacent timelines", function() { + const oldTimeline = room.addTimeline(); + + room.addEventsToTimeline([events[0]], false, oldTimeline); + room.addLiveEvents([events[1]]); + + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), + events[1].getId())) + .toBe(null); + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), + events[0].getId())) + .toBe(null); + }); + + it("should return null for unknown events", function() { + room.addLiveEvents(events); + + expect(room.getUnfilteredTimelineSet() + .compareEventOrdering(events[0].getId(), "xxx")) + .toBe(null); + expect(room.getUnfilteredTimelineSet() + .compareEventOrdering("xxx", events[0].getId())) + .toBe(null); + expect(room.getUnfilteredTimelineSet() + .compareEventOrdering(events[0].getId(), events[0].getId())) + .toBe(0); + }); + }); + + describe("getJoinedMembers", function() { + it("should return members whose membership is 'join'", function() { + room.currentState.getMembers.andCall(function() { + return [ + { userId: "@alice:bar", membership: "join" }, + { userId: "@bob:bar", membership: "invite" }, + { userId: "@cleo:bar", membership: "leave" }, + ]; + }); + const res = room.getJoinedMembers(); + expect(res.length).toEqual(1); + expect(res[0].userId).toEqual("@alice:bar"); + }); + + it("should return an empty list if no membership is 'join'", function() { + room.currentState.getMembers.andCall(function() { + return [ + { userId: "@bob:bar", membership: "invite" }, + ]; + }); + const res = room.getJoinedMembers(); + expect(res.length).toEqual(0); + }); + }); + + describe("hasMembershipState", function() { + it("should return true for a matching userId and membership", + function() { + room.currentState.getMember.andCall(function(userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + "@bob:bar": { userId: "@bob:bar", membership: "invite" }, + }[userId]; + }); + expect(room.hasMembershipState("@bob:bar", "invite")).toBe(true); + }); + + it("should return false if match membership but no match userId", + function() { + room.currentState.getMember.andCall(function(userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + }[userId]; + }); + expect(room.hasMembershipState("@bob:bar", "join")).toBe(false); + }); + + it("should return false if match userId but no match membership", + function() { + room.currentState.getMember.andCall(function(userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + }[userId]; + }); + expect(room.hasMembershipState("@alice:bar", "ban")).toBe(false); + }); + + it("should return false if no match membership or userId", + function() { + room.currentState.getMember.andCall(function(userId) { + return { + "@alice:bar": { userId: "@alice:bar", membership: "join" }, + }[userId]; + }); + expect(room.hasMembershipState("@bob:bar", "invite")).toBe(false); + }); + + it("should return false if no members exist", + function() { + expect(room.hasMembershipState("@foo:bar", "join")).toBe(false); + }); + }); + + describe("recalculate", function() { + const setJoinRule = function(rule) { + room.addLiveEvents([utils.mkEvent({ + type: "m.room.join_rules", room: roomId, user: userA, content: { + join_rule: rule, + }, event: true, + })]); + }; + const setAliases = function(aliases, stateKey) { + if (!stateKey) { + stateKey = "flibble"; + } + room.addLiveEvents([utils.mkEvent({ + type: "m.room.aliases", room: roomId, skey: stateKey, content: { + aliases: aliases, + }, event: true, + })]); + }; + const setRoomName = function(name) { + room.addLiveEvents([utils.mkEvent({ + type: "m.room.name", room: roomId, user: userA, content: { + name: name, + }, event: true, + })]); + }; + const addMember = function(userId, state, opts) { + if (!state) { + state = "join"; + } + opts = opts || {}; + opts.room = roomId; + opts.mship = state; + opts.user = opts.user || userId; + opts.skey = userId; + opts.event = true; + const event = utils.mkMembership(opts); + room.addLiveEvents([event]); + return event; + }; + + beforeEach(function() { + // no mocking + room = new Room(roomId, null, userA); + }); + + describe("Room.recalculate => Stripped State Events", function() { + it("should set stripped state events as actual state events if the " + + "room is an invite room", function() { + const roomName = "flibble"; + + const event = addMember(userA, "invite"); + event.event.invite_room_state = [ + { + type: "m.room.name", + state_key: "", + content: { + name: roomName, + }, + }, + ]; + + room.recalculate(); + expect(room.name).toEqual(roomName); + }); + + it("should not clobber state events if it isn't an invite room", function() { + const event = addMember(userA, "join"); + const roomName = "flibble"; + setRoomName(roomName); + const roomNameToIgnore = "ignoreme"; + event.event.invite_room_state = [ + { + type: "m.room.name", + state_key: "", + content: { + name: roomNameToIgnore, + }, + }, + ]; + + room.recalculate(); + expect(room.name).toEqual(roomName); + }); + }); + + describe("Room.recalculate => Room Name using room summary", function() { + it("should use room heroes if available", function() { + addMember(userA, "invite"); + addMember(userB); + addMember(userC); + addMember(userD); + room.setSummary({ + "m.heroes": [userB, userC, userD], + }); + + room.recalculate(); + expect(room.name).toEqual(`${userB} and 2 others`); + }); + + it("missing hero member state reverts to mxid", function() { + room.setSummary({ + "m.heroes": [userB], + "m.joined_member_count": 2, + }); + + room.recalculate(); + expect(room.name).toEqual(userB); + }); + + it("uses hero name from state", function() { + const name = "Mr B"; + addMember(userA, "invite"); + addMember(userB, "join", {name}); + room.setSummary({ + "m.heroes": [userB], + }); + + room.recalculate(); + expect(room.name).toEqual(name); + }); + + it("uses counts from summary", function() { + const name = "Mr B"; + addMember(userB, "join", {name}); + room.setSummary({ + "m.heroes": [userB], + "m.joined_member_count": 50, + "m.invited_member_count": 50, + }); + room.recalculate(); + expect(room.name).toEqual(`${name} and 98 others`); + }); + + it("relies on heroes in case of absent counts", function() { + const nameB = "Mr Bean"; + const nameC = "Mel C"; + addMember(userB, "join", {name: nameB}); + addMember(userC, "join", {name: nameC}); + room.setSummary({ + "m.heroes": [userB, userC], + }); + room.recalculate(); + expect(room.name).toEqual(`${nameB} and ${nameC}`); + }); + + it("uses only heroes", function() { + const nameB = "Mr Bean"; + addMember(userB, "join", {name: nameB}); + addMember(userC, "join"); + room.setSummary({ + "m.heroes": [userB], + }); + room.recalculate(); + expect(room.name).toEqual(nameB); + }); + + it("reverts to empty room in case of self chat", function() { + room.setSummary({ + "m.heroes": [], + "m.invited_member_count": 1, + }); + room.recalculate(); + expect(room.name).toEqual("Empty room"); + }); + }); + + describe("Room.recalculate => Room Name", function() { + it("should return the names of members in a private (invite join_rules)" + + " room if a room name and alias don't exist and there are >3 members.", + function() { + setJoinRule("invite"); + addMember(userA); + addMember(userB); + addMember(userC); + addMember(userD); + room.recalculate(); + const name = room.name; + // we expect at least 1 member to be mentioned + const others = [userB, userC, userD]; + let found = false; + for (let i = 0; i < others.length; i++) { + if (name.indexOf(others[i]) !== -1) { + found = true; + break; + } + } + expect(found).toEqual(true, name); + }); + + it("should return the names of members in a private (invite join_rules)" + + " room if a room name and alias don't exist and there are >2 members.", + function() { + setJoinRule("invite"); + addMember(userA); + addMember(userB); + addMember(userC); + room.recalculate(); + const name = room.name; + expect(name.indexOf(userB)).toNotEqual(-1, name); + expect(name.indexOf(userC)).toNotEqual(-1, name); + }); + + it("should return the names of members in a public (public join_rules)" + + " room if a room name and alias don't exist and there are >2 members.", + function() { + setJoinRule("public"); + addMember(userA); + addMember(userB); + addMember(userC); + room.recalculate(); + const name = room.name; + expect(name.indexOf(userB)).toNotEqual(-1, name); + expect(name.indexOf(userC)).toNotEqual(-1, name); + }); + + it("should show the other user's name for public (public join_rules)" + + " rooms if a room name and alias don't exist and it is a 1:1-chat.", + function() { + setJoinRule("public"); + addMember(userA); + addMember(userB); + room.recalculate(); + const name = room.name; + expect(name.indexOf(userB)).toNotEqual(-1, name); + }); + + it("should show the other user's name for private " + + "(invite join_rules) rooms if a room name and alias don't exist and it" + + " is a 1:1-chat.", function() { + setJoinRule("invite"); + addMember(userA); + addMember(userB); + room.recalculate(); + const name = room.name; + expect(name.indexOf(userB)).toNotEqual(-1, name); + }); + + it("should show the other user's name for private" + + " (invite join_rules) rooms if you are invited to it.", function() { + setJoinRule("invite"); + addMember(userA, "invite", {user: userB}); + addMember(userB); + room.recalculate(); + const name = room.name; + expect(name.indexOf(userB)).toNotEqual(-1, name); + }); + + it("should show the room alias if one exists for private " + + "(invite join_rules) rooms if a room name doesn't exist.", function() { + const alias = "#room_alias:here"; + setJoinRule("invite"); + setAliases([alias, "#another:one"]); + room.recalculate(); + const name = room.name; + expect(name).toEqual(alias); + }); + + it("should show the room alias if one exists for public " + + "(public join_rules) rooms if a room name doesn't exist.", function() { + const alias = "#room_alias:here"; + setJoinRule("public"); + setAliases([alias, "#another:one"]); + room.recalculate(); + const name = room.name; + expect(name).toEqual(alias); + }); + + it("should show the room name if one exists for private " + + "(invite join_rules) rooms.", function() { + const roomName = "A mighty name indeed"; + setJoinRule("invite"); + setRoomName(roomName); + room.recalculate(); + const name = room.name; + expect(name).toEqual(roomName); + }); + + it("should show the room name if one exists for public " + + "(public join_rules) rooms.", function() { + const roomName = "A mighty name indeed"; + setJoinRule("public"); + setRoomName(roomName); + room.recalculate(); + expect(room.name).toEqual(roomName); + }); + + it("should return 'Empty room' for private (invite join_rules) rooms if" + + " a room name and alias don't exist and it is a self-chat.", function() { + setJoinRule("invite"); + addMember(userA); + room.recalculate(); + expect(room.name).toEqual("Empty room"); + }); + + it("should return 'Empty room' for public (public join_rules) rooms if a" + + " room name and alias don't exist and it is a self-chat.", function() { + setJoinRule("public"); + addMember(userA); + room.recalculate(); + const name = room.name; + expect(name).toEqual("Empty room"); + }); + + it("should return 'Empty room' if there is no name, " + + "alias or members in the room.", + function() { + room.recalculate(); + const name = room.name; + expect(name).toEqual("Empty room"); + }); + + it("should return '[inviter display name] if state event " + + "available", + function() { + setJoinRule("invite"); + addMember(userB, 'join', {name: "Alice"}); + addMember(userA, "invite", {user: userA}); + room.recalculate(); + const name = room.name; + expect(name).toEqual("Alice"); + }); + + it("should return inviter mxid if display name not available", + function() { + setJoinRule("invite"); + addMember(userB); + addMember(userA, "invite", {user: userA}); + room.recalculate(); + const name = room.name; + expect(name).toEqual(userB); + }); + }); + }); + + describe("receipts", function() { + const eventToAck = utils.mkMessage({ + room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE", + event: true, + }); + + function mkReceipt(roomId, records) { + const content = {}; + records.forEach(function(r) { + if (!content[r.eventId]) { + content[r.eventId] = {}; + } + if (!content[r.eventId][r.type]) { + content[r.eventId][r.type] = {}; + } + content[r.eventId][r.type][r.userId] = { + ts: r.ts, + }; + }); + return new MatrixEvent({ + content: content, + room_id: roomId, + type: "m.receipt", + }); + } + + function mkRecord(eventId, type, userId, ts) { + ts = ts || Date.now(); + return { + eventId: eventId, + type: type, + userId: userId, + ts: ts, + }; + } + + describe("addReceipt", function() { + it("should store the receipt so it can be obtained via getReceiptsForEvent", + function() { + const ts = 13787898424; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + ])); + expect(room.getReceiptsForEvent(eventToAck)).toEqual([{ + type: "m.read", + userId: userB, + data: { + ts: ts, + }, + }]); + }); + + it("should emit an event when a receipt is added", + function() { + const listener = expect.createSpy(); + room.on("Room.receipt", listener); + + const ts = 13787898424; + + const receiptEvent = mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + ]); + + room.addReceipt(receiptEvent); + expect(listener).toHaveBeenCalledWith(receiptEvent, room); + }); + + it("should clobber receipts based on type and user ID", function() { + const nextEventToAck = utils.mkMessage({ + room: roomId, user: userA, msg: "I AM HERE YOU KNOW", + event: true, + }); + const ts = 13787898424; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + ])); + const ts2 = 13787899999; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(nextEventToAck.getId(), "m.read", userB, ts2), + ])); + expect(room.getReceiptsForEvent(eventToAck)).toEqual([]); + expect(room.getReceiptsForEvent(nextEventToAck)).toEqual([{ + type: "m.read", + userId: userB, + data: { + ts: ts2, + }, + }]); + }); + + it("should persist multiple receipts for a single event ID", function() { + const ts = 13787898424; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + mkRecord(eventToAck.getId(), "m.read", userC, ts), + mkRecord(eventToAck.getId(), "m.read", userD, ts), + ])); + expect(room.getUsersReadUpTo(eventToAck)).toEqual( + [userB, userC, userD], + ); + }); + + it("should persist multiple receipts for a single receipt type", function() { + const eventTwo = utils.mkMessage({ + room: roomId, user: userA, msg: "2222", + event: true, + }); + const eventThree = utils.mkMessage({ + room: roomId, user: userA, msg: "3333", + event: true, + }); + const ts = 13787898424; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + mkRecord(eventTwo.getId(), "m.read", userC, ts), + mkRecord(eventThree.getId(), "m.read", userD, ts), + ])); + expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]); + expect(room.getUsersReadUpTo(eventTwo)).toEqual([userC]); + expect(room.getUsersReadUpTo(eventThree)).toEqual([userD]); + }); + + it("should persist multiple receipts for a single user ID", function() { + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.delivered", userB, 13787898424), + mkRecord(eventToAck.getId(), "m.read", userB, 22222222), + mkRecord(eventToAck.getId(), "m.seen", userB, 33333333), + ])); + expect(room.getReceiptsForEvent(eventToAck)).toEqual([ + { + type: "m.delivered", + userId: userB, + data: { + ts: 13787898424, + }, + }, + { + type: "m.read", + userId: userB, + data: { + ts: 22222222, + }, + }, + { + type: "m.seen", + userId: userB, + data: { + ts: 33333333, + }, + }, + ]); + }); + + it("should prioritise the most recent event", function() { + const events = [ + utils.mkMessage({ + room: roomId, user: userA, msg: "1111", + event: true, + }), + utils.mkMessage({ + room: roomId, user: userA, msg: "2222", + event: true, + }), + utils.mkMessage({ + room: roomId, user: userA, msg: "3333", + event: true, + }), + ]; + + room.addLiveEvents(events); + const ts = 13787898424; + + // check it initialises correctly + room.addReceipt(mkReceipt(roomId, [ + mkRecord(events[0].getId(), "m.read", userB, ts), + ])); + expect(room.getEventReadUpTo(userB)).toEqual(events[0].getId()); + + // 2>0, so it should move forward + room.addReceipt(mkReceipt(roomId, [ + mkRecord(events[2].getId(), "m.read", userB, ts), + ])); + expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId()); + + // 1<2, so it should stay put + room.addReceipt(mkReceipt(roomId, [ + mkRecord(events[1].getId(), "m.read", userB, ts), + ])); + expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId()); + }); + }); + + describe("getUsersReadUpTo", function() { + it("should return user IDs read up to the given event", function() { + const ts = 13787898424; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + ])); + expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]); + }); + }); + }); + + describe("tags", function() { + function mkTags(roomId, tags) { + const content = { "tags": tags }; + return new MatrixEvent({ + content: content, + room_id: roomId, + type: "m.tag", + }); + } + + describe("addTag", function() { + it("should set tags on rooms from event stream so " + + "they can be obtained by the tags property", + function() { + const tags = { "m.foo": { "order": 0.5 } }; + room.addTags(mkTags(roomId, tags)); + expect(room.tags).toEqual(tags); + }); + + it("should emit Room.tags event when new tags are " + + "received on the event stream", + function() { + const listener = expect.createSpy(); + room.on("Room.tags", listener); + + const tags = { "m.foo": { "order": 0.5 } }; + const event = mkTags(roomId, tags); + room.addTags(event); + expect(listener).toHaveBeenCalledWith(event, room); + }); + + // XXX: shouldn't we try injecting actual m.tag events onto the eventstream + // rather than injecting via room.addTags()? + }); + }); + + describe("addPendingEvent", function() { + it("should add pending events to the pendingEventList if " + + "pendingEventOrdering == 'detached'", function() { + const room = new Room(roomId, null, userA, { + pendingEventOrdering: "detached", + }); + const eventA = utils.mkMessage({ + room: roomId, user: userA, msg: "remote 1", event: true, + }); + const eventB = utils.mkMessage({ + room: roomId, user: userA, msg: "local 1", event: true, + }); + eventB.status = EventStatus.SENDING; + const eventC = utils.mkMessage({ + room: roomId, user: userA, msg: "remote 2", event: true, + }); + room.addLiveEvents([eventA]); + room.addPendingEvent(eventB, "TXN1"); + room.addLiveEvents([eventC]); + expect(room.timeline).toEqual( + [eventA, eventC], + ); + expect(room.getPendingEvents()).toEqual( + [eventB], + ); + }); + + it("should add pending events to the timeline if " + + "pendingEventOrdering == 'chronological'", function() { + room = new Room(roomId, null, userA, { + pendingEventOrdering: "chronological", + }); + const eventA = utils.mkMessage({ + room: roomId, user: userA, msg: "remote 1", event: true, + }); + const eventB = utils.mkMessage({ + room: roomId, user: userA, msg: "local 1", event: true, + }); + eventB.status = EventStatus.SENDING; + const eventC = utils.mkMessage({ + room: roomId, user: userA, msg: "remote 2", event: true, + }); + room.addLiveEvents([eventA]); + room.addPendingEvent(eventB, "TXN1"); + room.addLiveEvents([eventC]); + expect(room.timeline).toEqual( + [eventA, eventB, eventC], + ); + }); + }); + + describe("updatePendingEvent", function() { + it("should remove cancelled events from the pending list", function() { + const room = new Room(roomId, null, userA, { + pendingEventOrdering: "detached", + }); + const eventA = utils.mkMessage({ + room: roomId, user: userA, event: true, + }); + eventA.status = EventStatus.SENDING; + const eventId = eventA.getId(); + + room.addPendingEvent(eventA, "TXN1"); + expect(room.getPendingEvents()).toEqual( + [eventA], + ); + + // the event has to have been failed or queued before it can be + // cancelled + room.updatePendingEvent(eventA, EventStatus.NOT_SENT); + + let callCount = 0; + room.on("Room.localEchoUpdated", + function(event, emitRoom, oldEventId, oldStatus) { + expect(event).toEqual(eventA); + expect(event.status).toEqual(EventStatus.CANCELLED); + expect(emitRoom).toEqual(room); + expect(oldEventId).toEqual(eventId); + expect(oldStatus).toEqual(EventStatus.NOT_SENT); + callCount++; + }); + + room.updatePendingEvent(eventA, EventStatus.CANCELLED); + expect(room.getPendingEvents()).toEqual([]); + expect(callCount).toEqual(1); + }); + + + it("should remove cancelled events from the timeline", function() { + const room = new Room(roomId, null, userA); + const eventA = utils.mkMessage({ + room: roomId, user: userA, event: true, + }); + eventA.status = EventStatus.SENDING; + const eventId = eventA.getId(); + + room.addPendingEvent(eventA, "TXN1"); + expect(room.getLiveTimeline().getEvents()).toEqual( + [eventA], + ); + + // the event has to have been failed or queued before it can be + // cancelled + room.updatePendingEvent(eventA, EventStatus.NOT_SENT); + + let callCount = 0; + room.on("Room.localEchoUpdated", + function(event, emitRoom, oldEventId, oldStatus) { + expect(event).toEqual(eventA); + expect(event.status).toEqual(EventStatus.CANCELLED); + expect(emitRoom).toEqual(room); + expect(oldEventId).toEqual(eventId); + expect(oldStatus).toEqual(EventStatus.NOT_SENT); + callCount++; + }); + + room.updatePendingEvent(eventA, EventStatus.CANCELLED); + expect(room.getLiveTimeline().getEvents()).toEqual([]); + expect(callCount).toEqual(1); + }); + }); + + describe("loadMembersIfNeeded", function() { + function createClientMock(serverResponse, storageResponse = null) { + return { + getEventMapper: function() { + // events should already be MatrixEvents + return function(event) {return event;}; + }, + isCryptoEnabled() { + return true; + }, + isRoomEncrypted: function() { + return false; + }, + _http: { + serverResponse, + authedRequest: function() { + if (this.serverResponse instanceof Error) { + return Promise.reject(this.serverResponse); + } else { + return Promise.resolve({chunk: this.serverResponse}); + } + }, + }, + store: { + storageResponse, + storedMembers: null, + getOutOfBandMembers: function() { + if (this.storageResponse instanceof Error) { + return Promise.reject(this.storageResponse); + } else { + return Promise.resolve(this.storageResponse); + } + }, + setOutOfBandMembers: function(roomId, memberEvents) { + this.storedMembers = memberEvents; + return Promise.resolve(); + }, + getSyncToken: () => "sync_token", + }, + }; + } + + const memberEvent = utils.mkMembership({ + user: "@user_a:bar", mship: "join", + room: roomId, event: true, name: "User A", + }); + + it("should load members from server on first call", async function() { + const client = createClientMock([memberEvent]); + const room = new Room(roomId, client, null, {lazyLoadMembers: true}); + await room.loadMembersIfNeeded(); + const memberA = room.getMember("@user_a:bar"); + expect(memberA.name).toEqual("User A"); + const storedMembers = client.store.storedMembers; + expect(storedMembers.length).toEqual(1); + expect(storedMembers[0].event_id).toEqual(memberEvent.getId()); + }); + + it("should take members from storage if available", async function() { + const memberEvent2 = utils.mkMembership({ + user: "@user_a:bar", mship: "join", + room: roomId, event: true, name: "Ms A", + }); + const client = createClientMock([memberEvent2], [memberEvent]); + const room = new Room(roomId, client, null, {lazyLoadMembers: true}); + + await room.loadMembersIfNeeded(); + + const memberA = room.getMember("@user_a:bar"); + expect(memberA.name).toEqual("User A"); + }); + + it("should allow retry on error", async function() { + const client = createClientMock(new Error("server says no")); + const room = new Room(roomId, client, null, {lazyLoadMembers: true}); + let hasThrown = false; + try { + await room.loadMembersIfNeeded(); + } catch(err) { + hasThrown = true; + } + expect(hasThrown).toEqual(true); + + client._http.serverResponse = [memberEvent]; + await room.loadMembersIfNeeded(); + const memberA = room.getMember("@user_a:bar"); + expect(memberA.name).toEqual("User A"); + }); + }); + + describe("getMyMembership", function() { + it("should return synced membership if membership isn't available yet", + function() { + const room = new Room(roomId, null, userA); + room.updateMyMembership("invite"); + expect(room.getMyMembership()).toEqual("invite"); + }); + it("should emit a Room.myMembership event on a change", + function() { + const room = new Room(roomId, null, userA); + const events = []; + room.on("Room.myMembership", (_room, membership, oldMembership) => { + events.push({membership, oldMembership}); + }); + room.updateMyMembership("invite"); + expect(room.getMyMembership()).toEqual("invite"); + expect(events[0]).toEqual({membership: "invite", oldMembership: null}); + events.splice(0); //clear + room.updateMyMembership("invite"); + expect(events.length).toEqual(0); + room.updateMyMembership("join"); + expect(room.getMyMembership()).toEqual("join"); + expect(events[0]).toEqual({membership: "join", oldMembership: "invite"}); + }); + }); + + describe("guessDMUserId", function() { + it("should return first hero id", + function() { + const room = new Room(roomId, null, userA); + room.setSummary({'m.heroes': [userB]}); + expect(room.guessDMUserId()).toEqual(userB); + }); + it("should return first member that isn't self", + function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, + })]); + expect(room.guessDMUserId()).toEqual(userB); + }); + it("should return self if only member present", + function() { + const room = new Room(roomId, null, userA); + expect(room.guessDMUserId()).toEqual(userA); + }); + }); + + describe("maySendMessage", function() { + it("should return false if synced membership not join", + function() { + const room = new Room(roomId, null, userA); + room.updateMyMembership("invite"); + expect(room.maySendMessage()).toEqual(false); + room.updateMyMembership("leave"); + expect(room.maySendMessage()).toEqual(false); + room.updateMyMembership("join"); + expect(room.maySendMessage()).toEqual(true); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/scheduler.spec.js b/matrix-js-sdk/spec/unit/scheduler.spec.js new file mode 100644 index 000000000..b518e8323 --- /dev/null +++ b/matrix-js-sdk/spec/unit/scheduler.spec.js @@ -0,0 +1,374 @@ +// This file had a function whose name is all caps, which displeases eslint +/* eslint new-cap: "off" */ + +import 'source-map-support/register'; +import Promise from 'bluebird'; +const sdk = require("../.."); +const MatrixScheduler = sdk.MatrixScheduler; +const MatrixError = sdk.MatrixError; +const utils = require("../test-utils"); + +import expect from 'expect'; +import lolex from 'lolex'; + +describe("MatrixScheduler", function() { + let clock; + let scheduler; + let retryFn; + let queueFn; + let defer; + const roomId = "!foo:bar"; + const eventA = utils.mkMessage({ + user: "@alice:bar", room: roomId, event: true, + }); + const eventB = utils.mkMessage({ + user: "@alice:bar", room: roomId, event: true, + }); + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + clock = lolex.install(); + scheduler = new MatrixScheduler(function(ev, attempts, err) { + if (retryFn) { + return retryFn(ev, attempts, err); + } + return -1; + }, function(event) { + if (queueFn) { + return queueFn(event); + } + return null; + }); + retryFn = null; + queueFn = null; + defer = Promise.defer(); + }); + + afterEach(function() { + clock.uninstall(); + }); + + it("should process events in a queue in a FIFO manner", async function() { + retryFn = function() { + return 0; + }; + queueFn = function() { + return "one_big_queue"; + }; + const deferA = Promise.defer(); + const deferB = Promise.defer(); + let yieldedA = false; + scheduler.setProcessFunction(function(event) { + if (yieldedA) { + expect(event).toEqual(eventB); + return deferB.promise; + } else { + yieldedA = true; + expect(event).toEqual(eventA); + return deferA.promise; + } + }); + const abPromise = Promise.all([ + scheduler.queueEvent(eventA), + scheduler.queueEvent(eventB), + ]); + deferB.resolve({b: true}); + deferA.resolve({a: true}); + const [a, b] = await abPromise; + expect(a.a).toEqual(true); + expect(b.b).toEqual(true); + }); + + it("should invoke the retryFn on failure and wait the amount of time specified", + async function() { + const waitTimeMs = 1500; + const retryDefer = Promise.defer(); + retryFn = function() { + retryDefer.resolve(); + return waitTimeMs; + }; + queueFn = function() { + return "yep"; + }; + + let procCount = 0; + scheduler.setProcessFunction(function(ev) { + procCount += 1; + if (procCount === 1) { + expect(ev).toEqual(eventA); + return defer.promise; + } else if (procCount === 2) { + // don't care about this defer + return new Promise(); + } + expect(procCount).toBeLessThan(3); + }); + + scheduler.queueEvent(eventA); + // as queueing doesn't start processing synchronously anymore (see commit bbdb5ac) + // wait just long enough before it does + await Promise.resolve(); + expect(procCount).toEqual(1); + defer.reject({}); + await retryDefer.promise; + expect(procCount).toEqual(1); + clock.tick(waitTimeMs); + await Promise.resolve(); + expect(procCount).toEqual(2); + }); + + it("should give up if the retryFn on failure returns -1 and try the next event", + async function() { + // Queue A & B. + // Reject A and return -1 on retry. + // Expect B to be tried next and the promise for A to be rejected. + retryFn = function() { + return -1; + }; + queueFn = function() { + return "yep"; + }; + + const deferA = Promise.defer(); + const deferB = Promise.defer(); + let procCount = 0; + scheduler.setProcessFunction(function(ev) { + procCount += 1; + if (procCount === 1) { + expect(ev).toEqual(eventA); + return deferA.promise; + } else if (procCount === 2) { + expect(ev).toEqual(eventB); + return deferB.promise; + } + expect(procCount).toBeLessThan(3); + }); + + const globalA = scheduler.queueEvent(eventA); + scheduler.queueEvent(eventB); + // as queueing doesn't start processing synchronously anymore (see commit bbdb5ac) + // wait just long enough before it does + await Promise.resolve(); + expect(procCount).toEqual(1); + deferA.reject({}); + try { + await globalA; + } catch(err) { + await Promise.resolve(); + expect(procCount).toEqual(2); + } + }); + + it("should treat each queue separately", function(done) { + // Queue messages A B C D. + // Bucket A&D into queue_A + // Bucket B&C into queue_B + // Expect to have processFn invoked for A&B. + // Resolve A. + // Expect to have processFn invoked for D. + const eventC = utils.mkMessage({user: "@a:bar", room: roomId, event: true}); + const eventD = utils.mkMessage({user: "@b:bar", room: roomId, event: true}); + + const buckets = {}; + buckets[eventA.getId()] = "queue_A"; + buckets[eventD.getId()] = "queue_A"; + buckets[eventB.getId()] = "queue_B"; + buckets[eventC.getId()] = "queue_B"; + + retryFn = function() { + return 0; + }; + queueFn = function(event) { + return buckets[event.getId()]; + }; + + const expectOrder = [ + eventA.getId(), eventB.getId(), eventD.getId(), + ]; + const deferA = Promise.defer(); + scheduler.setProcessFunction(function(event) { + const id = expectOrder.shift(); + expect(id).toEqual(event.getId()); + if (expectOrder.length === 0) { + done(); + } + return id === eventA.getId() ? deferA.promise : defer.promise; + }); + scheduler.queueEvent(eventA); + scheduler.queueEvent(eventB); + scheduler.queueEvent(eventC); + scheduler.queueEvent(eventD); + + // wait a bit then resolve A and we should get D (not C) next. + setTimeout(function() { + deferA.resolve({}); + }, 1000); + clock.tick(1000); + }); + + describe("queueEvent", function() { + it("should return null if the event shouldn't be queued", function() { + queueFn = function() { + return null; + }; + expect(scheduler.queueEvent(eventA)).toEqual(null); + }); + + it("should return a Promise if the event is queued", function() { + queueFn = function() { + return "yep"; + }; + const prom = scheduler.queueEvent(eventA); + expect(prom).toBeTruthy(); + expect(prom.then).toBeTruthy(); + }); + }); + + describe("getQueueForEvent", function() { + it("should return null if the event doesn't map to a queue name", function() { + queueFn = function() { + return null; + }; + expect(scheduler.getQueueForEvent(eventA)).toBe(null); + }); + + it("should return null if the mapped queue doesn't exist", function() { + queueFn = function() { + return "yep"; + }; + expect(scheduler.getQueueForEvent(eventA)).toBe(null); + }); + + it("should return a list of events in the queue and modifications to" + + " the list should not affect the underlying queue.", function() { + queueFn = function() { + return "yep"; + }; + scheduler.queueEvent(eventA); + scheduler.queueEvent(eventB); + const queue = scheduler.getQueueForEvent(eventA); + expect(queue.length).toEqual(2); + expect(queue).toEqual([eventA, eventB]); + // modify the queue + const eventC = utils.mkMessage( + {user: "@a:bar", room: roomId, event: true}, + ); + queue.push(eventC); + const queueAgain = scheduler.getQueueForEvent(eventA); + expect(queueAgain.length).toEqual(2); + }); + + it("should return a list of events in the queue and modifications to" + + " an event in the queue should affect the underlying queue.", function() { + queueFn = function() { + return "yep"; + }; + scheduler.queueEvent(eventA); + scheduler.queueEvent(eventB); + const queue = scheduler.getQueueForEvent(eventA); + queue[1].event.content.body = "foo"; + const queueAgain = scheduler.getQueueForEvent(eventA); + expect(queueAgain[1].event.content.body).toEqual("foo"); + }); + }); + + describe("removeEventFromQueue", function() { + it("should return false if the event doesn't map to a queue name", function() { + queueFn = function() { + return null; + }; + expect(scheduler.removeEventFromQueue(eventA)).toBe(false); + }); + + it("should return false if the event isn't in the queue", function() { + queueFn = function() { + return "yep"; + }; + expect(scheduler.removeEventFromQueue(eventA)).toBe(false); + }); + + it("should return true if the event was removed", function() { + queueFn = function() { + return "yep"; + }; + scheduler.queueEvent(eventA); + expect(scheduler.removeEventFromQueue(eventA)).toBe(true); + }); + }); + + describe("setProcessFunction", function() { + it("should call the processFn if there are queued events", function() { + queueFn = function() { + return "yep"; + }; + let procCount = 0; + scheduler.queueEvent(eventA); + scheduler.setProcessFunction(function(ev) { + procCount += 1; + expect(ev).toEqual(eventA); + return defer.promise; + }); + // as queueing doesn't start processing synchronously anymore (see commit bbdb5ac) + // wait just long enough before it does + Promise.resolve().then(() => { + expect(procCount).toEqual(1); + }); + }); + + it("should not call the processFn if there are no queued events", function() { + queueFn = function() { + return "yep"; + }; + let procCount = 0; + scheduler.setProcessFunction(function(ev) { + procCount += 1; + return defer.promise; + }); + expect(procCount).toEqual(0); + }); + }); + + describe("QUEUE_MESSAGES", function() { + it("should queue m.room.message events only", function() { + expect(MatrixScheduler.QUEUE_MESSAGES(eventA)).toEqual("message"); + expect(MatrixScheduler.QUEUE_MESSAGES( + utils.mkMembership({ + user: "@alice:bar", room: roomId, mship: "join", event: true, + }), + )).toEqual(null); + }); + }); + + describe("RETRY_BACKOFF_RATELIMIT", function() { + it("should wait at least the time given on M_LIMIT_EXCEEDED", function() { + const res = MatrixScheduler.RETRY_BACKOFF_RATELIMIT( + eventA, 1, new MatrixError({ + errcode: "M_LIMIT_EXCEEDED", retry_after_ms: 5000, + }), + ); + expect(res >= 500).toBe(true, "Didn't wait long enough."); + }); + + it("should give up after 5 attempts", function() { + const res = MatrixScheduler.RETRY_BACKOFF_RATELIMIT( + eventA, 5, {}, + ); + expect(res).toBe(-1, "Didn't give up."); + }); + + it("should do exponential backoff", function() { + expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT( + eventA, 1, {}, + )).toEqual(2000); + expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT( + eventA, 2, {}, + )).toEqual(4000); + expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT( + eventA, 3, {}, + )).toEqual(8000); + expect(MatrixScheduler.RETRY_BACKOFF_RATELIMIT( + eventA, 4, {}, + )).toEqual(16000); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/sync-accumulator.spec.js b/matrix-js-sdk/spec/unit/sync-accumulator.spec.js new file mode 100644 index 000000000..5f067b0b7 --- /dev/null +++ b/matrix-js-sdk/spec/unit/sync-accumulator.spec.js @@ -0,0 +1,413 @@ +/* +Copyright 2017 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. +*/ + +"use strict"; +import 'source-map-support/register'; +import utils from "../test-utils"; +import sdk from "../.."; +import expect from 'expect'; + +const SyncAccumulator = sdk.SyncAccumulator; + +describe("SyncAccumulator", function() { + let sa; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + sa = new SyncAccumulator({ + maxTimelineEntries: 10, + }); + }); + + it("should return the same /sync response if accumulated exactly once", () => { + // technically cheating since we also cheekily pre-populate keys we + // know that the sync accumulator will pre-populate. + // It isn't 100% transitive. + const res = { + next_batch: "abc", + rooms: { + invite: {}, + leave: {}, + join: { + "!foo:bar": { + account_data: { events: [] }, + ephemeral: { events: [] }, + unread_notifications: {}, + state: { + events: [ + member("alice", "join"), + member("bob", "join"), + ], + }, + summary: { + "m.heroes": undefined, + "m.joined_member_count": undefined, + "m.invited_member_count": undefined, + }, + timeline: { + events: [msg("alice", "hi")], + prev_batch: "something", + }, + }, + }, + }, + }; + sa.accumulate(res); + const output = sa.getJSON(); + expect(output.nextBatch).toEqual(res.next_batch); + expect(output.roomsData).toEqual(res.rooms); + }); + + it("should prune the timeline to the oldest prev_batch within the limit", () => { + // maxTimelineEntries is 10 so we should get back all + // 10 timeline messages with a prev_batch of "pinned_to_1" + sa.accumulate(syncSkeleton({ + state: { events: [member("alice", "join")] }, + timeline: { + events: [ + msg("alice", "1"), + msg("alice", "2"), + msg("alice", "3"), + msg("alice", "4"), + msg("alice", "5"), + msg("alice", "6"), + msg("alice", "7"), + ], + prev_batch: "pinned_to_1", + }, + })); + sa.accumulate(syncSkeleton({ + state: { events: [] }, + timeline: { + events: [ + msg("alice", "8"), + ], + prev_batch: "pinned_to_8", + }, + })); + sa.accumulate(syncSkeleton({ + state: { events: [] }, + timeline: { + events: [ + msg("alice", "9"), + msg("alice", "10"), + ], + prev_batch: "pinned_to_10", + }, + })); + + let output = sa.getJSON().roomsData.join["!foo:bar"]; + + expect(output.timeline.events.length).toEqual(10); + output.timeline.events.forEach((e, i) => { + expect(e.content.body).toEqual(""+(i+1)); + }); + expect(output.timeline.prev_batch).toEqual("pinned_to_1"); + + // accumulate more messages. Now it can't have a prev_batch of "pinned to 1" + // AND give us <= 10 messages without losing messages in-between. + // It should try to find the oldest prev_batch which still fits into 10 + // messages, which is "pinned to 8". + sa.accumulate(syncSkeleton({ + state: { events: [] }, + timeline: { + events: [ + msg("alice", "11"), + msg("alice", "12"), + msg("alice", "13"), + msg("alice", "14"), + msg("alice", "15"), + msg("alice", "16"), + msg("alice", "17"), + ], + prev_batch: "pinned_to_11", + }, + })); + + output = sa.getJSON().roomsData.join["!foo:bar"]; + + expect(output.timeline.events.length).toEqual(10); + output.timeline.events.forEach((e, i) => { + expect(e.content.body).toEqual(""+(i+8)); + }); + expect(output.timeline.prev_batch).toEqual("pinned_to_8"); + }); + + it("should remove the stored timeline on limited syncs", () => { + sa.accumulate(syncSkeleton({ + state: { events: [member("alice", "join")] }, + timeline: { + events: [ + msg("alice", "1"), + msg("alice", "2"), + msg("alice", "3"), + ], + prev_batch: "pinned_to_1", + }, + })); + // some time passes and now we get a limited sync + sa.accumulate(syncSkeleton({ + state: { events: [] }, + timeline: { + limited: true, + events: [ + msg("alice", "51"), + msg("alice", "52"), + msg("alice", "53"), + ], + prev_batch: "pinned_to_51", + }, + })); + + const output = sa.getJSON().roomsData.join["!foo:bar"]; + + expect(output.timeline.events.length).toEqual(3); + output.timeline.events.forEach((e, i) => { + expect(e.content.body).toEqual(""+(i+51)); + }); + expect(output.timeline.prev_batch).toEqual("pinned_to_51"); + }); + + it("should drop typing notifications", () => { + const res = syncSkeleton({ + ephemeral: { + events: [{ + type: "m.typing", + content: { + user_ids: ["@alice:localhost"], + }, + room_id: "!foo:bar", + }], + }, + }); + sa.accumulate(res); + expect( + sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events.length, + ).toEqual(0); + }); + + it("should clobber account data based on event type", () => { + const acc1 = { + type: "favourite.food", + content: { + food: "banana", + }, + }; + const acc2 = { + type: "favourite.food", + content: { + food: "apple", + }, + }; + sa.accumulate(syncSkeleton({ + account_data: { + events: [acc1], + }, + })); + sa.accumulate(syncSkeleton({ + account_data: { + events: [acc2], + }, + })); + expect( + sa.getJSON().roomsData.join["!foo:bar"].account_data.events.length, + ).toEqual(1); + expect( + sa.getJSON().roomsData.join["!foo:bar"].account_data.events[0], + ).toEqual(acc2); + }); + + it("should clobber global account data based on event type", () => { + const acc1 = { + type: "favourite.food", + content: { + food: "banana", + }, + }; + const acc2 = { + type: "favourite.food", + content: { + food: "apple", + }, + }; + sa.accumulate({ + account_data: { + events: [acc1], + }, + }); + sa.accumulate({ + account_data: { + events: [acc2], + }, + }); + expect( + sa.getJSON().accountData.length, + ).toEqual(1); + expect( + sa.getJSON().accountData[0], + ).toEqual(acc2); + }); + + it("should accumulate read receipts", () => { + const receipt1 = { + type: "m.receipt", + room_id: "!foo:bar", + content: { + "$event1:localhost": { + "m.read": { + "@alice:localhost": { ts: 1 }, + "@bob:localhost": { ts: 2 }, + }, + "some.other.receipt.type": { + "@should_be_ignored:localhost": { key: "val" }, + }, + }, + }, + }; + const receipt2 = { + type: "m.receipt", + room_id: "!foo:bar", + content: { + "$event2:localhost": { + "m.read": { + "@bob:localhost": { ts: 2 }, // clobbers event1 receipt + "@charlie:localhost": { ts: 3 }, + }, + }, + }, + }; + sa.accumulate(syncSkeleton({ + ephemeral: { + events: [receipt1], + }, + })); + sa.accumulate(syncSkeleton({ + ephemeral: { + events: [receipt2], + }, + })); + + expect( + sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events.length, + ).toEqual(1); + expect( + sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events[0], + ).toEqual({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + "$event1:localhost": { + "m.read": { + "@alice:localhost": { ts: 1 }, + }, + }, + "$event2:localhost": { + "m.read": { + "@bob:localhost": { ts: 2 }, + "@charlie:localhost": { ts: 3 }, + }, + }, + }, + }); + }); + + describe("summary field", function() { + function createSyncResponseWithSummary(summary) { + return { + next_batch: "abc", + rooms: { + invite: {}, + leave: {}, + join: { + "!foo:bar": { + account_data: { events: [] }, + ephemeral: { events: [] }, + unread_notifications: {}, + state: { + events: [], + }, + summary: summary, + timeline: { + events: [], + prev_batch: "something", + }, + }, + }, + }, + }; + } + + it("should copy summary properties", function() { + sa.accumulate(createSyncResponseWithSummary({ + "m.heroes": ["@alice:bar"], + "m.invited_member_count": 2, + })); + const summary = sa.getJSON().roomsData.join["!foo:bar"].summary; + expect(summary["m.invited_member_count"]).toEqual(2); + expect(summary["m.heroes"]).toEqual(["@alice:bar"]); + }); + + it("should accumulate summary properties", function() { + sa.accumulate(createSyncResponseWithSummary({ + "m.heroes": ["@alice:bar"], + "m.invited_member_count": 2, + })); + sa.accumulate(createSyncResponseWithSummary({ + "m.heroes": ["@bob:bar"], + "m.joined_member_count": 5, + })); + const summary = sa.getJSON().roomsData.join["!foo:bar"].summary; + expect(summary["m.invited_member_count"]).toEqual(2); + expect(summary["m.joined_member_count"]).toEqual(5); + expect(summary["m.heroes"]).toEqual(["@bob:bar"]); + }); + }); +}); + +function syncSkeleton(joinObj) { + joinObj = joinObj || {}; + return { + next_batch: "abc", + rooms: { + join: { + "!foo:bar": joinObj, + }, + }, + }; +} + +function msg(localpart, text) { + return { + content: { + body: text, + }, + origin_server_ts: 123456789, + sender: "@" + localpart + ":localhost", + type: "m.room.message", + }; +} + +function member(localpart, membership) { + return { + content: { + membership: membership, + }, + origin_server_ts: 123456789, + state_key: "@" + localpart + ":localhost", + sender: "@" + localpart + ":localhost", + type: "m.room.member", + }; +} diff --git a/matrix-js-sdk/spec/unit/timeline-window.spec.js b/matrix-js-sdk/spec/unit/timeline-window.spec.js new file mode 100644 index 000000000..3637fe968 --- /dev/null +++ b/matrix-js-sdk/spec/unit/timeline-window.spec.js @@ -0,0 +1,477 @@ +"use strict"; +import 'source-map-support/register'; +import Promise from 'bluebird'; +const sdk = require("../.."); +const EventTimeline = sdk.EventTimeline; +const TimelineWindow = sdk.TimelineWindow; +const TimelineIndex = require("../../lib/timeline-window").TimelineIndex; + +const utils = require("../test-utils"); +import expect from 'expect'; + +const ROOM_ID = "roomId"; +const USER_ID = "userId"; + +/* + * create a timeline with a bunch (default 3) events. + * baseIndex is 1 by default. + */ +function createTimeline(numEvents, baseIndex) { + if (numEvents === undefined) { + numEvents = 3; + } + if (baseIndex === undefined) { + baseIndex = 1; + } + + // XXX: this is a horrid hack + const timelineSet = { room: { roomId: ROOM_ID }}; + timelineSet.room.getUnfilteredTimelineSet = function() { + return timelineSet; + }; + + const timeline = new EventTimeline(timelineSet); + + // add the events after the baseIndex first + addEventsToTimeline(timeline, numEvents - baseIndex, false); + + // then add those before the baseIndex + addEventsToTimeline(timeline, baseIndex, true); + + expect(timeline.getBaseIndex()).toEqual(baseIndex); + return timeline; +} + +function addEventsToTimeline(timeline, numEvents, atStart) { + for (let i = 0; i < numEvents; i++) { + timeline.addEvent( + utils.mkMessage({ + room: ROOM_ID, user: USER_ID, + event: true, + }), atStart, + ); + } +} + + +/* + * create a pair of linked timelines + */ +function createLinkedTimelines() { + const tl1 = createTimeline(); + const tl2 = createTimeline(); + tl1.setNeighbouringTimeline(tl2, EventTimeline.FORWARDS); + tl2.setNeighbouringTimeline(tl1, EventTimeline.BACKWARDS); + return [tl1, tl2]; +} + + +describe("TimelineIndex", function() { + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + }); + + describe("minIndex", function() { + it("should return the min index relative to BaseIndex", function() { + const timelineIndex = new TimelineIndex(createTimeline(), 0); + expect(timelineIndex.minIndex()).toEqual(-1); + }); + }); + + describe("maxIndex", function() { + it("should return the max index relative to BaseIndex", function() { + const timelineIndex = new TimelineIndex(createTimeline(), 0); + expect(timelineIndex.maxIndex()).toEqual(2); + }); + }); + + describe("advance", function() { + it("should advance up to the end of the timeline", function() { + const timelineIndex = new TimelineIndex(createTimeline(), 0); + const result = timelineIndex.advance(3); + expect(result).toEqual(2); + expect(timelineIndex.index).toEqual(2); + }); + + it("should retreat back to the start of the timeline", function() { + const timelineIndex = new TimelineIndex(createTimeline(), 0); + const result = timelineIndex.advance(-2); + expect(result).toEqual(-1); + expect(timelineIndex.index).toEqual(-1); + }); + + it("should advance into the next timeline", function() { + const timelines = createLinkedTimelines(); + const tl1 = timelines[0]; + const tl2 = timelines[1]; + + // initialise the index pointing at the end of the first timeline + const timelineIndex = new TimelineIndex(tl1, 2); + + const result = timelineIndex.advance(1); + expect(result).toEqual(1); + expect(timelineIndex.timeline).toBe(tl2); + + // we expect the index to be the zero (ie, the same as the + // BaseIndex), because the BaseIndex points at the second event, + // and we've advanced past the first. + expect(timelineIndex.index).toEqual(0); + }); + + it("should retreat into the previous timeline", function() { + const timelines = createLinkedTimelines(); + const tl1 = timelines[0]; + const tl2 = timelines[1]; + + // initialise the index pointing at the start of the second + // timeline + const timelineIndex = new TimelineIndex(tl2, -1); + + const result = timelineIndex.advance(-1); + expect(result).toEqual(-1); + expect(timelineIndex.timeline).toBe(tl1); + expect(timelineIndex.index).toEqual(1); + }); + }); + + describe("retreat", function() { + it("should retreat up to the start of the timeline", function() { + const timelineIndex = new TimelineIndex(createTimeline(), 0); + const result = timelineIndex.retreat(2); + expect(result).toEqual(1); + expect(timelineIndex.index).toEqual(-1); + }); + }); +}); + + +describe("TimelineWindow", function() { + /** + * create a dummy eventTimelineSet and client, and a TimelineWindow + * attached to them. + */ + let timelineSet; + let client; + function createWindow(timeline, opts) { + timelineSet = {}; + client = {}; + client.getEventTimeline = function(timelineSet0, eventId0) { + expect(timelineSet0).toBe(timelineSet); + return Promise.resolve(timeline); + }; + + return new TimelineWindow(client, timelineSet, opts); + } + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + }); + + describe("load", function() { + it("should initialise from the live timeline", function(done) { + const liveTimeline = createTimeline(); + const room = {}; + room.getLiveTimeline = function() { + return liveTimeline; + }; + + const timelineWindow = new TimelineWindow(undefined, room); + timelineWindow.load(undefined, 2).then(function() { + const expectedEvents = liveTimeline.getEvents().slice(1); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + }).nodeify(done); + }); + + it("should initialise from a specific event", function(done) { + const timeline = createTimeline(); + const eventId = timeline.getEvents()[1].getId(); + + const timelineSet = {}; + const client = {}; + client.getEventTimeline = function(timelineSet0, eventId0) { + expect(timelineSet0).toBe(timelineSet); + expect(eventId0).toEqual(eventId); + return Promise.resolve(timeline); + }; + + const timelineWindow = new TimelineWindow(client, timelineSet); + timelineWindow.load(eventId, 3).then(function() { + const expectedEvents = timeline.getEvents(); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + }).nodeify(done); + }); + + it("canPaginate should return false until load has returned", + function(done) { + const timeline = createTimeline(); + timeline.setPaginationToken("toktok1", EventTimeline.BACKWARDS); + timeline.setPaginationToken("toktok2", EventTimeline.FORWARDS); + + const eventId = timeline.getEvents()[1].getId(); + + const timelineSet = {}; + const client = {}; + + const timelineWindow = new TimelineWindow(client, timelineSet); + + client.getEventTimeline = function(timelineSet0, eventId0) { + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(false); + return Promise.resolve(timeline); + }; + + timelineWindow.load(eventId, 3).then(function() { + const expectedEvents = timeline.getEvents(); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(true); + }).nodeify(done); + }); + }); + + describe("pagination", function() { + it("should be able to advance across the initial timeline", + function(done) { + const timeline = createTimeline(); + const eventId = timeline.getEvents()[1].getId(); + const timelineWindow = createWindow(timeline); + + timelineWindow.load(eventId, 1).then(function() { + const expectedEvents = [timeline.getEvents()[1]]; + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(true); + + return timelineWindow.paginate(EventTimeline.FORWARDS, 2); + }).then(function(success) { + expect(success).toBe(true); + const expectedEvents = timeline.getEvents().slice(1); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(false); + + return timelineWindow.paginate(EventTimeline.FORWARDS, 2); + }).then(function(success) { + expect(success).toBe(false); + + return timelineWindow.paginate(EventTimeline.BACKWARDS, 2); + }).then(function(success) { + expect(success).toBe(true); + const expectedEvents = timeline.getEvents(); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(false); + return timelineWindow.paginate(EventTimeline.BACKWARDS, 2); + }).then(function(success) { + expect(success).toBe(false); + }).nodeify(done); + }); + + it("should advance into next timeline", function(done) { + const tls = createLinkedTimelines(); + const eventId = tls[0].getEvents()[1].getId(); + const timelineWindow = createWindow(tls[0], {windowLimit: 5}); + + timelineWindow.load(eventId, 3).then(function() { + const expectedEvents = tls[0].getEvents(); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(true); + + return timelineWindow.paginate(EventTimeline.FORWARDS, 2); + }).then(function(success) { + expect(success).toBe(true); + const expectedEvents = tls[0].getEvents() + .concat(tls[1].getEvents().slice(0, 2)); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(true); + + return timelineWindow.paginate(EventTimeline.FORWARDS, 2); + }).then(function(success) { + expect(success).toBe(true); + // the windowLimit should have made us drop an event from + // tls[0] + const expectedEvents = tls[0].getEvents().slice(1) + .concat(tls[1].getEvents()); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(false); + return timelineWindow.paginate(EventTimeline.FORWARDS, 2); + }).then(function(success) { + expect(success).toBe(false); + }).nodeify(done); + }); + + it("should retreat into previous timeline", function(done) { + const tls = createLinkedTimelines(); + const eventId = tls[1].getEvents()[1].getId(); + const timelineWindow = createWindow(tls[1], {windowLimit: 5}); + + timelineWindow.load(eventId, 3).then(function() { + const expectedEvents = tls[1].getEvents(); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(false); + + return timelineWindow.paginate(EventTimeline.BACKWARDS, 2); + }).then(function(success) { + expect(success).toBe(true); + const expectedEvents = tls[0].getEvents().slice(1, 3) + .concat(tls[1].getEvents()); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(false); + + return timelineWindow.paginate(EventTimeline.BACKWARDS, 2); + }).then(function(success) { + expect(success).toBe(true); + // the windowLimit should have made us drop an event from + // tls[1] + const expectedEvents = tls[0].getEvents() + .concat(tls[1].getEvents().slice(0, 2)); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(true); + return timelineWindow.paginate(EventTimeline.BACKWARDS, 2); + }).then(function(success) { + expect(success).toBe(false); + }).nodeify(done); + }); + + it("should make forward pagination requests", function(done) { + const timeline = createTimeline(); + timeline.setPaginationToken("toktok", EventTimeline.FORWARDS); + + const timelineWindow = createWindow(timeline, {windowLimit: 5}); + const eventId = timeline.getEvents()[1].getId(); + + client.paginateEventTimeline = function(timeline0, opts) { + expect(timeline0).toBe(timeline); + expect(opts.backwards).toBe(false); + expect(opts.limit).toEqual(2); + + addEventsToTimeline(timeline, 3, false); + return Promise.resolve(true); + }; + + timelineWindow.load(eventId, 3).then(function() { + const expectedEvents = timeline.getEvents(); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(true); + return timelineWindow.paginate(EventTimeline.FORWARDS, 2); + }).then(function(success) { + expect(success).toBe(true); + const expectedEvents = timeline.getEvents().slice(0, 5); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + }).nodeify(done); + }); + + + it("should make backward pagination requests", function(done) { + const timeline = createTimeline(); + timeline.setPaginationToken("toktok", EventTimeline.BACKWARDS); + + const timelineWindow = createWindow(timeline, {windowLimit: 5}); + const eventId = timeline.getEvents()[1].getId(); + + client.paginateEventTimeline = function(timeline0, opts) { + expect(timeline0).toBe(timeline); + expect(opts.backwards).toBe(true); + expect(opts.limit).toEqual(2); + + addEventsToTimeline(timeline, 3, true); + return Promise.resolve(true); + }; + + timelineWindow.load(eventId, 3).then(function() { + const expectedEvents = timeline.getEvents(); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(true); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(false); + return timelineWindow.paginate(EventTimeline.BACKWARDS, 2); + }).then(function(success) { + expect(success).toBe(true); + const expectedEvents = timeline.getEvents().slice(1, 6); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + }).nodeify(done); + }); + + it("should limit the number of unsuccessful pagination requests", + function(done) { + const timeline = createTimeline(); + timeline.setPaginationToken("toktok", EventTimeline.FORWARDS); + + const timelineWindow = createWindow(timeline, {windowLimit: 5}); + const eventId = timeline.getEvents()[1].getId(); + + let paginateCount = 0; + client.paginateEventTimeline = function(timeline0, opts) { + expect(timeline0).toBe(timeline); + expect(opts.backwards).toBe(false); + expect(opts.limit).toEqual(2); + paginateCount += 1; + return Promise.resolve(true); + }; + + timelineWindow.load(eventId, 3).then(function() { + const expectedEvents = timeline.getEvents(); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(true); + return timelineWindow.paginate(EventTimeline.FORWARDS, 2, true, 3); + }).then(function(success) { + expect(success).toBe(false); + expect(paginateCount).toEqual(3); + const expectedEvents = timeline.getEvents().slice(0, 3); + expect(timelineWindow.getEvents()).toEqual(expectedEvents); + + expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) + .toBe(false); + expect(timelineWindow.canPaginate(EventTimeline.FORWARDS)) + .toBe(true); + }).nodeify(done); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/user.spec.js b/matrix-js-sdk/spec/unit/user.spec.js new file mode 100644 index 000000000..7448cdec9 --- /dev/null +++ b/matrix-js-sdk/spec/unit/user.spec.js @@ -0,0 +1,87 @@ +"use strict"; +import 'source-map-support/register'; +const sdk = require("../.."); +const User = sdk.User; +const utils = require("../test-utils"); + +import expect from 'expect'; + +describe("User", function() { + const userId = "@alice:bar"; + let user; + + beforeEach(function() { + utils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + user = new User(userId); + }); + + describe("setPresenceEvent", function() { + const event = utils.mkEvent({ + type: "m.presence", content: { + presence: "online", + user_id: userId, + displayname: "Alice", + last_active_ago: 1085, + avatar_url: "mxc://foo/bar", + }, event: true, + }); + + it("should emit 'User.displayName' if the display name changes", function() { + let emitCount = 0; + user.on("User.displayName", function(ev, usr) { + emitCount += 1; + }); + user.setPresenceEvent(event); + expect(emitCount).toEqual(1); + user.setPresenceEvent(event); // no-op + expect(emitCount).toEqual(1); + }); + + it("should emit 'User.avatarUrl' if the avatar URL changes", function() { + let emitCount = 0; + user.on("User.avatarUrl", function(ev, usr) { + emitCount += 1; + }); + user.setPresenceEvent(event); + expect(emitCount).toEqual(1); + user.setPresenceEvent(event); // no-op + expect(emitCount).toEqual(1); + }); + + it("should emit 'User.presence' if the presence changes", function() { + let emitCount = 0; + user.on("User.presence", function(ev, usr) { + emitCount += 1; + }); + user.setPresenceEvent(event); + expect(emitCount).toEqual(1); + user.setPresenceEvent(event); // no-op + expect(emitCount).toEqual(1); + }); + + it("should set User.displayName", function() { + user.setPresenceEvent(event); + expect(user.displayName).toEqual("Alice"); + }); + + it("should set User.avatarUrl", function() { + user.setPresenceEvent(event); + expect(user.avatarUrl).toEqual("mxc://foo/bar"); + }); + + it("should set User.presence", function() { + user.setPresenceEvent(event); + expect(user.presence).toEqual("online"); + }); + + it("should set User.lastActiveAgo", function() { + user.setPresenceEvent(event); + expect(user.lastActiveAgo).toEqual(1085); + }); + + it("should set User.events.presence", function() { + user.setPresenceEvent(event); + expect(user.events.presence).toEqual(event); + }); + }); +}); diff --git a/matrix-js-sdk/spec/unit/utils.spec.js b/matrix-js-sdk/spec/unit/utils.spec.js new file mode 100644 index 000000000..ca451eb81 --- /dev/null +++ b/matrix-js-sdk/spec/unit/utils.spec.js @@ -0,0 +1,294 @@ +"use strict"; +import 'source-map-support/register'; +const utils = require("../../lib/utils"); +const testUtils = require("../test-utils"); + +import expect from 'expect'; + +describe("utils", function() { + beforeEach(function() { + testUtils.beforeEach(this); // eslint-disable-line babel/no-invalid-this + }); + + describe("encodeParams", function() { + it("should url encode and concat with &s", function() { + const params = { + foo: "bar", + baz: "beer@", + }; + expect(utils.encodeParams(params)).toEqual( + "foo=bar&baz=beer%40", + ); + }); + }); + + describe("encodeUri", function() { + it("should replace based on object keys and url encode", function() { + const path = "foo/bar/%something/%here"; + const vals = { + "%something": "baz", + "%here": "beer@", + }; + expect(utils.encodeUri(path, vals)).toEqual( + "foo/bar/baz/beer%40", + ); + }); + }); + + describe("forEach", function() { + it("should be invoked for each element", function() { + const arr = []; + utils.forEach([55, 66, 77], function(element) { + arr.push(element); + }); + expect(arr).toEqual([55, 66, 77]); + }); + }); + + describe("findElement", function() { + it("should find only 1 element if there is a match", function() { + const matchFn = function() { + return true; + }; + const arr = [55, 66, 77]; + expect(utils.findElement(arr, matchFn)).toEqual(55); + }); + it("should be able to find in reverse order", function() { + const matchFn = function() { + return true; + }; + const arr = [55, 66, 77]; + expect(utils.findElement(arr, matchFn, true)).toEqual(77); + }); + it("should find nothing if the function never returns true", function() { + const matchFn = function() { + return false; + }; + const arr = [55, 66, 77]; + expect(utils.findElement(arr, matchFn)).toBeFalsy(); + }); + }); + + describe("removeElement", function() { + it("should remove only 1 element if there is a match", function() { + const matchFn = function() { + return true; + }; + const arr = [55, 66, 77]; + utils.removeElement(arr, matchFn); + expect(arr).toEqual([66, 77]); + }); + it("should be able to remove in reverse order", function() { + const matchFn = function() { + return true; + }; + const arr = [55, 66, 77]; + utils.removeElement(arr, matchFn, true); + expect(arr).toEqual([55, 66]); + }); + it("should remove nothing if the function never returns true", function() { + const matchFn = function() { + return false; + }; + const arr = [55, 66, 77]; + utils.removeElement(arr, matchFn); + expect(arr).toEqual(arr); + }); + }); + + describe("isFunction", function() { + it("should return true for functions", function() { + expect(utils.isFunction([])).toBe(false); + expect(utils.isFunction([5, 3, 7])).toBe(false); + expect(utils.isFunction()).toBe(false); + expect(utils.isFunction(null)).toBe(false); + expect(utils.isFunction({})).toBe(false); + expect(utils.isFunction("foo")).toBe(false); + expect(utils.isFunction(555)).toBe(false); + + expect(utils.isFunction(function() {})).toBe(true); + const s = { foo: function() {} }; + expect(utils.isFunction(s.foo)).toBe(true); + }); + }); + + describe("isArray", function() { + it("should return true for arrays", function() { + expect(utils.isArray([])).toBe(true); + expect(utils.isArray([5, 3, 7])).toBe(true); + + expect(utils.isArray()).toBe(false); + expect(utils.isArray(null)).toBe(false); + expect(utils.isArray({})).toBe(false); + expect(utils.isArray("foo")).toBe(false); + expect(utils.isArray(555)).toBe(false); + expect(utils.isArray(function() {})).toBe(false); + }); + }); + + describe("checkObjectHasKeys", function() { + it("should throw for missing keys", function() { + expect(function() { + utils.checkObjectHasKeys({}, ["foo"]); + }).toThrow(); + expect(function() { + utils.checkObjectHasKeys({ + foo: "bar", + }, ["foo"]); + }).toNotThrow(); + }); + }); + + describe("checkObjectHasNoAdditionalKeys", function() { + it("should throw for extra keys", function() { + expect(function() { + utils.checkObjectHasNoAdditionalKeys({ + foo: "bar", + baz: 4, + }, ["foo"]); + }).toThrow(); + + expect(function() { + utils.checkObjectHasNoAdditionalKeys({ + foo: "bar", + }, ["foo"]); + }).toNotThrow(); + }); + }); + + describe("deepCompare", function() { + const assert = { + isTrue: function(x) { + expect(x).toBe(true); + }, + isFalse: function(x) { + expect(x).toBe(false); + }, + }; + + it("should handle primitives", function() { + assert.isTrue(utils.deepCompare(null, null)); + assert.isFalse(utils.deepCompare(null, undefined)); + assert.isTrue(utils.deepCompare("hi", "hi")); + assert.isTrue(utils.deepCompare(5, 5)); + assert.isFalse(utils.deepCompare(5, 10)); + }); + + it("should handle regexps", function() { + assert.isTrue(utils.deepCompare(/abc/, /abc/)); + assert.isFalse(utils.deepCompare(/abc/, /123/)); + const r = /abc/; + assert.isTrue(utils.deepCompare(r, r)); + }); + + it("should handle dates", function() { + assert.isTrue(utils.deepCompare(new Date("2011-03-31"), + new Date("2011-03-31"))); + assert.isFalse(utils.deepCompare(new Date("2011-03-31"), + new Date("1970-01-01"))); + }); + + it("should handle arrays", function() { + assert.isTrue(utils.deepCompare([], [])); + assert.isTrue(utils.deepCompare([1, 2], [1, 2])); + assert.isFalse(utils.deepCompare([1, 2], [2, 1])); + assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3])); + }); + + it("should handle simple objects", function() { + assert.isTrue(utils.deepCompare({}, {})); + assert.isTrue(utils.deepCompare({a: 1, b: 2}, {a: 1, b: 2})); + assert.isTrue(utils.deepCompare({a: 1, b: 2}, {b: 2, a: 1})); + assert.isFalse(utils.deepCompare({a: 1, b: 2}, {a: 1, b: 3})); + + assert.isTrue(utils.deepCompare({1: {name: "mhc", age: 28}, + 2: {name: "arb", age: 26}}, + {1: {name: "mhc", age: 28}, + 2: {name: "arb", age: 26}})); + + assert.isFalse(utils.deepCompare({1: {name: "mhc", age: 28}, + 2: {name: "arb", age: 26}}, + {1: {name: "mhc", age: 28}, + 2: {name: "arb", age: 27}})); + + assert.isFalse(utils.deepCompare({}, null)); + assert.isFalse(utils.deepCompare({}, undefined)); + }); + + it("should handle functions", function() { + // no two different function is equal really, they capture their + // context variables so even if they have same toString(), they + // won't have same functionality + const func = function(x) { + return true; + }; + const func2 = function(x) { + return true; + }; + assert.isTrue(utils.deepCompare(func, func)); + assert.isFalse(utils.deepCompare(func, func2)); + assert.isTrue(utils.deepCompare({ a: { b: func } }, { a: { b: func } })); + assert.isFalse(utils.deepCompare({ a: { b: func } }, { a: { b: func2 } })); + }); + }); + + + describe("extend", function() { + const SOURCE = { "prop2": 1, "string2": "x", "newprop": "new" }; + + it("should extend", function() { + const target = { + "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", + }; + const merged = { + "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", + "newprop": "new", + }; + const sourceOrig = JSON.stringify(SOURCE); + + utils.extend(target, SOURCE); + expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); + + // check the originial wasn't modified + expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); + }); + + it("should ignore null", function() { + const target = { + "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", + }; + const merged = { + "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", + "newprop": "new", + }; + const sourceOrig = JSON.stringify(SOURCE); + + utils.extend(target, null, SOURCE); + expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); + + // check the originial wasn't modified + expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); + }); + + it("should handle properties created with defineProperties", function() { + const source = Object.defineProperties({}, { + "enumerableProp": { + get: function() { + return true; + }, + enumerable: true, + }, + "nonenumerableProp": { + get: function() { + return true; + }, + }, + }); + + const target = {}; + utils.extend(target, source); + expect(target.enumerableProp).toBe(true); + expect(target.nonenumerableProp).toBe(undefined); + }); + }); +}); diff --git a/matrix-js-sdk/src/ReEmitter.js b/matrix-js-sdk/src/ReEmitter.js new file mode 100644 index 000000000..4ddf3229a --- /dev/null +++ b/matrix-js-sdk/src/ReEmitter.js @@ -0,0 +1,52 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2017 New Vector 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. +*/ + +/** + * @module + */ + +export default class Reemitter { + constructor(target) { + this.target = target; + + // We keep one bound event handler for each event name so we know + // what event is arriving + this.boundHandlers = {}; + } + + _handleEvent(eventName, ...args) { + this.target.emit(eventName, ...args); + } + + reEmit(source, eventNames) { + // We include the source as the last argument for event handlers which may need it, + // such as read receipt listeners on the client class which won't have the context + // of the room. + const forSource = (handler, ...args) => { + handler(...args, source); + }; + for (const eventName of eventNames) { + if (this.boundHandlers[eventName] === undefined) { + this.boundHandlers[eventName] = this._handleEvent.bind(this, eventName); + } + + const boundHandler = forSource.bind(this, this.boundHandlers[eventName]); + source.on(eventName, boundHandler); + } + } +} diff --git a/matrix-js-sdk/src/autodiscovery.js b/matrix-js-sdk/src/autodiscovery.js new file mode 100644 index 000000000..900c01663 --- /dev/null +++ b/matrix-js-sdk/src/autodiscovery.js @@ -0,0 +1,524 @@ +/* +Copyright 2018 New Vector 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. +*/ + +/** @module auto-discovery */ + +import Promise from 'bluebird'; +import logger from './logger'; +import { URL as NodeURL } from "url"; + +// Dev note: Auto discovery is part of the spec. +// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery + +/** + * Description for what an automatically discovered client configuration + * would look like. Although this is a class, it is recommended that it + * be treated as an interface definition rather than as a class. + * + * Additional properties than those defined here may be present, and + * should follow the Java package naming convention. + */ +class DiscoveredClientConfig { // eslint-disable-line no-unused-vars + // Dev note: this is basically a copy/paste of the .well-known response + // object as defined in the spec. It does have additional information, + // however. Overall, this exists to serve as a place for documentation + // and not functionality. + // See https://matrix.org/docs/spec/client_server/r0.4.0.html#get-well-known-matrix-client + + constructor() { + /** + * The homeserver configuration the client should use. This will + * always be present on the object. + * @type {{state: string, base_url: string}} The configuration. + */ + this["m.homeserver"] = { + /** + * The lookup result state. If this is anything other than + * AutoDiscovery.SUCCESS then base_url may be falsey. Additionally, + * if this is not AutoDiscovery.SUCCESS then the client should + * assume the other properties in the client config (such as + * the identity server configuration) are not valid. + */ + state: AutoDiscovery.PROMPT, + + /** + * If the state is AutoDiscovery.FAIL_ERROR or .FAIL_PROMPT + * then this will contain a human-readable (English) message + * for what went wrong. If the state is none of those previously + * mentioned, this will be falsey. + */ + error: "Something went wrong", + + /** + * The base URL clients should use to talk to the homeserver, + * particularly for the login process. May be falsey if the + * state is not AutoDiscovery.SUCCESS. + */ + base_url: "https://matrix.org", + }; + + /** + * The identity server configuration the client should use. This + * will always be present on teh object. + * @type {{state: string, base_url: string}} The configuration. + */ + this["m.identity_server"] = { + /** + * The lookup result state. If this is anything other than + * AutoDiscovery.SUCCESS then base_url may be falsey. + */ + state: AutoDiscovery.PROMPT, + + /** + * The base URL clients should use for interacting with the + * identity server. May be falsey if the state is not + * AutoDiscovery.SUCCESS. + */ + base_url: "https://vector.im", + }; + } +} + +/** + * Utilities for automatically discovery resources, such as homeservers + * for users to log in to. + */ +export class AutoDiscovery { + // Dev note: the constants defined here are related to but not + // exactly the same as those in the spec. This is to hopefully + // translate the meaning of the states in the spec, but also + // support our own if needed. + + static get ERROR_INVALID() { + return "Invalid homeserver discovery response"; + } + + static get ERROR_GENERIC_FAILURE() { + return "Failed to get autodiscovery configuration from server"; + } + + static get ERROR_INVALID_HS_BASE_URL() { + return "Invalid base_url for m.homeserver"; + } + + static get ERROR_INVALID_HOMESERVER() { + return "Homeserver URL does not appear to be a valid Matrix homeserver"; + } + + static get ERROR_INVALID_IS_BASE_URL() { + return "Invalid base_url for m.identity_server"; + } + + static get ERROR_INVALID_IDENTITY_SERVER() { + return "Identity server URL does not appear to be a valid identity server"; + } + + static get ERROR_INVALID_IS() { + return "Invalid identity server discovery response"; + } + + static get ERROR_MISSING_WELLKNOWN() { + return "No .well-known JSON file found"; + } + + static get ERROR_INVALID_JSON() { + return "Invalid JSON"; + } + + static get ALL_ERRORS() { + return [ + AutoDiscovery.ERROR_INVALID, + AutoDiscovery.ERROR_GENERIC_FAILURE, + AutoDiscovery.ERROR_INVALID_HS_BASE_URL, + AutoDiscovery.ERROR_INVALID_HOMESERVER, + AutoDiscovery.ERROR_INVALID_IS_BASE_URL, + AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, + AutoDiscovery.ERROR_INVALID_IS, + AutoDiscovery.ERROR_MISSING_WELLKNOWN, + AutoDiscovery.ERROR_INVALID_JSON, + ]; + } + + /** + * The auto discovery failed. The client is expected to communicate + * the error to the user and refuse logging in. + * @return {string} + * @constructor + */ + static get FAIL_ERROR() { return "FAIL_ERROR"; } + + /** + * The auto discovery failed, however the client may still recover + * from the problem. The client is recommended to that the same + * action it would for PROMPT while also warning the user about + * what went wrong. The client may also treat this the same as + * a FAIL_ERROR state. + * @return {string} + * @constructor + */ + static get FAIL_PROMPT() { return "FAIL_PROMPT"; } + + /** + * The auto discovery didn't fail but did not find anything of + * interest. The client is expected to prompt the user for more + * information, or fail if it prefers. + * @return {string} + * @constructor + */ + static get PROMPT() { return "PROMPT"; } + + /** + * The auto discovery was successful. + * @return {string} + * @constructor + */ + static get SUCCESS() { return "SUCCESS"; } + + /** + * Validates and verifies client configuration information for purposes + * of logging in. Such information includes the homeserver URL + * and identity server URL the client would want. Additional details + * may also be included, and will be transparently brought into the + * response object unaltered. + * @param {string} wellknown The configuration object itself, as returned + * by the .well-known auto-discovery endpoint. + * @return {Promise} Resolves to the verified + * configuration, which may include error states. Rejects on unexpected + * failure, not when verification fails. + */ + static async fromDiscoveryConfig(wellknown) { + // Step 1 is to get the config, which is provided to us here. + + // We default to an error state to make the first few checks easier to + // write. We'll update the properties of this object over the duration + // of this function. + const clientConfig = { + "m.homeserver": { + state: AutoDiscovery.FAIL_ERROR, + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + // Technically, we don't have a problem with the identity server + // config at this point. + state: AutoDiscovery.PROMPT, + error: null, + base_url: null, + }, + }; + + if (!wellknown || !wellknown["m.homeserver"]) { + logger.error("No m.homeserver key in config"); + + clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT; + clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID; + + return Promise.resolve(clientConfig); + } + + if (!wellknown["m.homeserver"]["base_url"]) { + logger.error("No m.homeserver base_url in config"); + + clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT; + clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL; + + return Promise.resolve(clientConfig); + } + + // Step 2: Make sure the homeserver URL is valid *looking*. We'll make + // sure it points to a homeserver in Step 3. + const hsUrl = this._sanitizeWellKnownUrl( + wellknown["m.homeserver"]["base_url"], + ); + if (!hsUrl) { + logger.error("Invalid base_url for m.homeserver"); + clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL; + return Promise.resolve(clientConfig); + } + + // Step 3: Make sure the homeserver URL points to a homeserver. + const hsVersions = await this._fetchWellKnownObject( + `${hsUrl}/_matrix/client/versions`, + ); + if (!hsVersions || !hsVersions.raw["versions"]) { + logger.error("Invalid /versions response"); + clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER; + + // Supply the base_url to the caller because they may be ignoring liveliness + // errors, like this one. + clientConfig["m.homeserver"].base_url = hsUrl; + + return Promise.resolve(clientConfig); + } + + // Step 4: Now that the homeserver looks valid, update our client config. + clientConfig["m.homeserver"] = { + state: AutoDiscovery.SUCCESS, + error: null, + base_url: hsUrl, + }; + + // Step 5: Try to pull out the identity server configuration + let isUrl = ""; + if (wellknown["m.identity_server"]) { + // We prepare a failing identity server response to save lines later + // in this branch. Note that we also fail the homeserver check in the + // object because according to the spec we're supposed to FAIL_ERROR + // if *anything* goes wrong with the IS validation, including invalid + // format. This means we're supposed to stop discovery completely. + const failingClientConfig = { + "m.homeserver": { + state: AutoDiscovery.FAIL_ERROR, + error: AutoDiscovery.ERROR_INVALID_IS, + + // We'll provide the base_url that was previously valid for + // debugging purposes. + base_url: clientConfig["m.homeserver"].base_url, + }, + "m.identity_server": { + state: AutoDiscovery.FAIL_ERROR, + error: AutoDiscovery.ERROR_INVALID_IS, + base_url: null, + }, + }; + + // Step 5a: Make sure the URL is valid *looking*. We'll make sure it + // points to an identity server in Step 5b. + isUrl = this._sanitizeWellKnownUrl( + wellknown["m.identity_server"]["base_url"], + ); + if (!isUrl) { + logger.error("Invalid base_url for m.identity_server"); + failingClientConfig["m.identity_server"].error = + AutoDiscovery.ERROR_INVALID_IS_BASE_URL; + return Promise.resolve(failingClientConfig); + } + + // Step 5b: Verify there is an identity server listening on the provided + // URL. + const isResponse = await this._fetchWellKnownObject( + `${isUrl}/_matrix/identity/api/v1`, + ); + if (!isResponse || !isResponse.raw || isResponse.action !== "SUCCESS") { + logger.error("Invalid /api/v1 response"); + failingClientConfig["m.identity_server"].error = + AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER; + + // Supply the base_url to the caller because they may be ignoring + // liveliness errors, like this one. + failingClientConfig["m.identity_server"].base_url = isUrl; + + return Promise.resolve(failingClientConfig); + } + } + + // Step 6: Now that the identity server is valid, or never existed, + // populate the IS section. + if (isUrl && isUrl.length > 0) { + clientConfig["m.identity_server"] = { + state: AutoDiscovery.SUCCESS, + error: null, + base_url: isUrl, + }; + } + + // Step 7: Copy any other keys directly into the clientConfig. This is for + // things like custom configuration of services. + Object.keys(wellknown) + .map((k) => { + if (k === "m.homeserver" || k === "m.identity_server") { + // Only copy selected parts of the config to avoid overwriting + // properties computed by the validation logic above. + const notProps = ["error", "state", "base_url"]; + for (const prop of Object.keys(wellknown[k])) { + if (notProps.includes(prop)) continue; + clientConfig[k][prop] = wellknown[k][prop]; + } + } else { + // Just copy the whole thing over otherwise + clientConfig[k] = wellknown[k]; + } + }); + + // Step 8: Give the config to the caller (finally) + return Promise.resolve(clientConfig); + } + + /** + * Attempts to automatically discover client configuration information + * prior to logging in. Such information includes the homeserver URL + * and identity server URL the client would want. Additional details + * may also be discovered, and will be transparently included in the + * response object unaltered. + * @param {string} domain The homeserver domain to perform discovery + * on. For example, "matrix.org". + * @return {Promise} Resolves to the discovered + * configuration, which may include error states. Rejects on unexpected + * failure, not when discovery fails. + */ + static async findClientConfig(domain) { + if (!domain || typeof(domain) !== "string" || domain.length === 0) { + throw new Error("'domain' must be a string of non-zero length"); + } + + // We use a .well-known lookup for all cases. According to the spec, we + // can do other discovery mechanisms if we want such as custom lookups + // however we won't bother with that here (mostly because the spec only + // supports .well-known right now). + // + // By using .well-known, we need to ensure we at least pull out a URL + // for the homeserver. We don't really need an identity server configuration + // but will return one anyways (with state PROMPT) to make development + // easier for clients. If we can't get a homeserver URL, all bets are + // off on the rest of the config and we'll assume it is invalid too. + + // We default to an error state to make the first few checks easier to + // write. We'll update the properties of this object over the duration + // of this function. + const clientConfig = { + "m.homeserver": { + state: AutoDiscovery.FAIL_ERROR, + error: AutoDiscovery.ERROR_INVALID, + base_url: null, + }, + "m.identity_server": { + // Technically, we don't have a problem with the identity server + // config at this point. + state: AutoDiscovery.PROMPT, + error: null, + base_url: null, + }, + }; + + // Step 1: Actually request the .well-known JSON file and make sure it + // at least has a homeserver definition. + const wellknown = await this._fetchWellKnownObject( + `https://${domain}/.well-known/matrix/client`, + ); + if (!wellknown || wellknown.action !== "SUCCESS") { + logger.error("No response or error when parsing .well-known"); + if (wellknown.reason) logger.error(wellknown.reason); + if (wellknown.action === "IGNORE") { + clientConfig["m.homeserver"] = { + state: AutoDiscovery.PROMPT, + error: null, + base_url: null, + }; + } else { + // this can only ever be FAIL_PROMPT at this point. + clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT; + clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID; + } + return Promise.resolve(clientConfig); + } + + // Step 2: Validate and parse the config + return AutoDiscovery.fromDiscoveryConfig(wellknown.raw); + } + + /** + * Sanitizes a given URL to ensure it is either an HTTP or HTTP URL and + * is suitable for the requirements laid out by .well-known auto discovery. + * If valid, the URL will also be stripped of any trailing slashes. + * @param {string} url The potentially invalid URL to sanitize. + * @return {string|boolean} The sanitized URL or a falsey value if the URL is invalid. + * @private + */ + static _sanitizeWellKnownUrl(url) { + if (!url) return false; + + try { + // We have to try and parse the URL using the NodeJS URL + // library if we're on NodeJS and use the browser's URL + // library when we're in a browser. To accomplish this, we + // try the NodeJS version first and fall back to the browser. + let parsed = null; + try { + if (NodeURL) parsed = new NodeURL(url); + else parsed = new URL(url); + } catch (e) { + parsed = new URL(url); + } + + if (!parsed || !parsed.hostname) return false; + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + + const port = parsed.port ? `:${parsed.port}` : ""; + const path = parsed.pathname ? parsed.pathname : ""; + let saferUrl = `${parsed.protocol}//${parsed.hostname}${port}${path}`; + if (saferUrl.endsWith("/")) { + saferUrl = saferUrl.substring(0, saferUrl.length - 1); + } + return saferUrl; + } catch (e) { + logger.error(e); + return false; + } + } + + /** + * Fetches a JSON object from a given URL, as expected by all .well-known + * related lookups. If the server gives a 404 then the `action` will be + * IGNORE. If the server returns something that isn't JSON, the `action` + * will be FAIL_PROMPT. For any other failure the `action` will be FAIL_PROMPT. + * + * The returned object will be a result of the call in object form with + * the following properties: + * raw: The JSON object returned by the server. + * action: One of SUCCESS, IGNORE, or FAIL_PROMPT. + * reason: Relatively human readable description of what went wrong. + * error: The actual Error, if one exists. + * @param {string} url The URL to fetch a JSON object from. + * @return {Promise} Resolves to the returned state. + * @private + */ + static async _fetchWellKnownObject(url) { + return new Promise(function(resolve, reject) { + const request = require("./matrix").getRequest(); + if (!request) throw new Error("No request library available"); + request( + { method: "GET", uri: url, timeout: 5000 }, + (err, response, body) => { + if (err || response.statusCode < 200 || response.statusCode >= 300) { + let action = "FAIL_PROMPT"; + let reason = (err ? err.message : null) || "General failure"; + if (response.statusCode === 404) { + action = "IGNORE"; + reason = AutoDiscovery.ERROR_MISSING_WELLKNOWN; + } + resolve({raw: {}, action: action, reason: reason, error: err}); + return; + } + + try { + resolve({raw: JSON.parse(body), action: "SUCCESS"}); + } catch (e) { + let reason = AutoDiscovery.ERROR_INVALID; + if (e.name === "SyntaxError") { + reason = AutoDiscovery.ERROR_INVALID_JSON; + } + resolve({ + raw: {}, + action: "FAIL_PROMPT", + reason: reason, + error: e, + }); + } + }, + ); + }); + } +} diff --git a/matrix-js-sdk/src/base-apis.js b/matrix-js-sdk/src/base-apis.js new file mode 100644 index 000000000..d93539e33 --- /dev/null +++ b/matrix-js-sdk/src/base-apis.js @@ -0,0 +1,1863 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 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. +*/ +"use strict"; + +/** + * This is an internal module. MatrixBaseApis is currently only meant to be used + * by {@link client~MatrixClient}. + * + * @module base-apis + */ + +const httpApi = require("./http-api"); +const utils = require("./utils"); + +/** + * Low-level wrappers for the Matrix APIs + * + * @constructor + * + * @param {Object} opts Configuration options + * + * @param {string} opts.baseUrl Required. The base URL to the client-server + * HTTP API. + * + * @param {string} opts.idBaseUrl Optional. The base identity server URL for + * identity server requests. + * + * @param {Function} opts.request Required. The function to invoke for HTTP + * requests. The value of this property is typically require("request") + * as it returns a function which meets the required interface. See + * {@link requestFunction} for more information. + * + * @param {string} opts.accessToken The access_token for this user. + * + * @param {Number=} opts.localTimeoutMs Optional. The default maximum amount of + * time to wait before timing out HTTP requests. If not specified, there is no + * timeout. + * + * @param {Object} opts.queryParams Optional. Extra query parameters to append + * to all requests with this client. Useful for application services which require + * ?user_id=. + * + * @param {boolean} [opts.useAuthorizationHeader = false] Set to true to use + * Authorization header instead of query param to send the access token to the server. + */ +function MatrixBaseApis(opts) { + utils.checkObjectHasKeys(opts, ["baseUrl", "request"]); + + this.baseUrl = opts.baseUrl; + this.idBaseUrl = opts.idBaseUrl; + + const httpOpts = { + baseUrl: opts.baseUrl, + idBaseUrl: opts.idBaseUrl, + accessToken: opts.accessToken, + request: opts.request, + prefix: httpApi.PREFIX_R0, + onlyData: true, + extraParams: opts.queryParams, + localTimeoutMs: opts.localTimeoutMs, + useAuthorizationHeader: opts.useAuthorizationHeader, + }; + this._http = new httpApi.MatrixHttpApi(this, httpOpts); + + this._txnCtr = 0; +} + +/** + * Get the Homeserver URL of this client + * @return {string} Homeserver URL of this client + */ +MatrixBaseApis.prototype.getHomeserverUrl = function() { + return this.baseUrl; +}; + +/** + * Get the Identity Server URL of this client + * @param {boolean} stripProto whether or not to strip the protocol from the URL + * @return {string} Identity Server URL of this client + */ +MatrixBaseApis.prototype.getIdentityServerUrl = function(stripProto=false) { + if (stripProto && (this.idBaseUrl.startsWith("http://") || + this.idBaseUrl.startsWith("https://"))) { + return this.idBaseUrl.split("://")[1]; + } + return this.idBaseUrl; +}; + +/** + * Get the access token associated with this account. + * @return {?String} The access_token or null + */ +MatrixBaseApis.prototype.getAccessToken = function() { + return this._http.opts.accessToken || null; +}; + +/** + * @return {boolean} true if there is a valid access_token for this client. + */ +MatrixBaseApis.prototype.isLoggedIn = function() { + return this._http.opts.accessToken !== undefined; +}; + +/** + * Make up a new transaction id + * + * @return {string} a new, unique, transaction id + */ +MatrixBaseApis.prototype.makeTxnId = function() { + return "m" + new Date().getTime() + "." + (this._txnCtr++); +}; + + +// Registration/Login operations +// ============================= + +/** + * Check whether a username is available prior to registration. An error response + * indicates an invalid/unavailable username. + * @param {string} username The username to check the availability of. + * @return {module:client.Promise} Resolves: to `true`. + */ +MatrixBaseApis.prototype.isUsernameAvailable = function(username) { + return this._http.authedRequest( + undefined, "GET", '/register/available', { username: username }, + ).then((response) => { + return response.available; + }); +}; + +/** + * @param {string} username + * @param {string} password + * @param {string} sessionId + * @param {Object} auth + * @param {Object} bindThreepids Set key 'email' to true to bind any email + * threepid uses during registration in the ID server. Set 'msisdn' to + * true to bind msisdn. + * @param {string} guestAccessToken + * @param {string} inhibitLogin + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.register = function( + username, password, + sessionId, auth, bindThreepids, guestAccessToken, inhibitLogin, + callback, +) { + // backwards compat + if (bindThreepids === true) { + bindThreepids = {email: true}; + } else if (bindThreepids === null || bindThreepids === undefined) { + bindThreepids = {}; + } + if (typeof inhibitLogin === 'function') { + callback = inhibitLogin; + inhibitLogin = undefined; + } + + if (auth === undefined || auth === null) { + auth = {}; + } + if (sessionId) { + auth.session = sessionId; + } + + const params = { + auth: auth, + }; + if (username !== undefined && username !== null) { + params.username = username; + } + if (password !== undefined && password !== null) { + params.password = password; + } + if (bindThreepids.email) { + params.bind_email = true; + } + if (bindThreepids.msisdn) { + params.bind_msisdn = true; + } + if (guestAccessToken !== undefined && guestAccessToken !== null) { + params.guest_access_token = guestAccessToken; + } + if (inhibitLogin !== undefined && inhibitLogin !== null) { + params.inhibit_login = inhibitLogin; + } + // Temporary parameter added to make the register endpoint advertise + // msisdn flows. This exists because there are clients that break + // when given stages they don't recognise. This parameter will cease + // to be necessary once these old clients are gone. + // Only send it if we send any params at all (the password param is + // mandatory, so if we send any params, we'll send the password param) + if (password !== undefined && password !== null) { + params.x_show_msisdn = true; + } + + return this.registerRequest(params, undefined, callback); +}; + +/** + * Register a guest account. + * @param {Object=} opts Registration options + * @param {Object} opts.body JSON HTTP body to provide. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.registerGuest = function(opts, callback) { + opts = opts || {}; + opts.body = opts.body || {}; + return this.registerRequest(opts.body, "guest", callback); +}; + +/** + * @param {Object} data parameters for registration request + * @param {string=} kind type of user to register. may be "guest" + * @param {module:client.callback=} callback + * @return {module:client.Promise} Resolves: to the /register response + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.registerRequest = function(data, kind, callback) { + const params = {}; + if (kind) { + params.kind = kind; + } + + return this._http.request( + callback, "POST", "/register", params, data, + ); +}; + +/** + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.loginFlows = function(callback) { + return this._http.request(callback, "GET", "/login"); +}; + +/** + * @param {string} loginType + * @param {Object} data + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.login = function(loginType, data, callback) { + const login_data = { + type: loginType, + }; + + // merge data into login_data + utils.extend(login_data, data); + + return this._http.authedRequest( + (error, response) => { + if (response && response.access_token && response.user_id) { + this._http.opts.accessToken = response.access_token; + this.credentials = { + userId: response.user_id, + }; + } + + if (callback) { + callback(error, response); + } + }, "POST", "/login", undefined, login_data, + ); +}; + +/** + * @param {string} user + * @param {string} password + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.loginWithPassword = function(user, password, callback) { + return this.login("m.login.password", { + user: user, + password: password, + }, callback); +}; + +/** + * @param {string} relayState URL Callback after SAML2 Authentication + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.loginWithSAML2 = function(relayState, callback) { + return this.login("m.login.saml2", { + relay_state: relayState, + }, callback); +}; + +/** + * @param {string} redirectUrl The URL to redirect to after the HS + * authenticates with CAS. + * @return {string} The HS URL to hit to begin the CAS login process. + */ +MatrixBaseApis.prototype.getCasLoginUrl = function(redirectUrl) { + return this.getSsoLoginUrl(redirectUrl, "cas"); +}; + +/** + * @param {string} redirectUrl The URL to redirect to after the HS + * authenticates with the SSO. + * @param {string} loginType The type of SSO login we are doing (sso or cas). + * Defaults to 'sso'. + * @return {string} The HS URL to hit to begin the SSO login process. + */ +MatrixBaseApis.prototype.getSsoLoginUrl = function(redirectUrl, loginType) { + if (loginType === undefined) { + loginType = "sso"; + } + return this._http.getUrl("/login/"+loginType+"/redirect", { + "redirectUrl": redirectUrl, + }, httpApi.PREFIX_R0); +}; + +/** + * @param {string} token Login token previously received from homeserver + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.loginWithToken = function(token, callback) { + return this.login("m.login.token", { + token: token, + }, callback); +}; + + +/** + * Logs out the current session. + * Obviously, further calls that require authorisation should fail after this + * method is called. The state of the MatrixClient object is not affected: + * it is up to the caller to either reset or destroy the MatrixClient after + * this method succeeds. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: On success, the empty object + */ +MatrixBaseApis.prototype.logout = function(callback) { + return this._http.authedRequest( + callback, "POST", '/logout', + ); +}; + +/** + * Deactivates the logged-in account. + * Obviously, further calls that require authorisation should fail after this + * method is called. The state of the MatrixClient object is not affected: + * it is up to the caller to either reset or destroy the MatrixClient after + * this method succeeds. + * @param {object} auth Optional. Auth data to supply for User-Interactive auth. + * @param {boolean} erase Optional. If set, send as `erase` attribute in the + * JSON request body, indicating whether the account should be erased. Defaults + * to false. + * @return {module:client.Promise} Resolves: On success, the empty object + */ +MatrixBaseApis.prototype.deactivateAccount = function(auth, erase) { + if (typeof(erase) === 'function') { + throw new Error( + 'deactivateAccount no longer accepts a callback parameter', + ); + } + + const body = {}; + if (auth) { + body.auth = auth; + } + if (erase !== undefined) { + body.erase = erase; + } + + return this._http.authedRequest( + undefined, "POST", '/account/deactivate', undefined, body, + ); +}; + +/** + * Get the fallback URL to use for unknown interactive-auth stages. + * + * @param {string} loginType the type of stage being attempted + * @param {string} authSessionId the auth session ID provided by the homeserver + * + * @return {string} HS URL to hit to for the fallback interface + */ +MatrixBaseApis.prototype.getFallbackAuthUrl = function(loginType, authSessionId) { + const path = utils.encodeUri("/auth/$loginType/fallback/web", { + $loginType: loginType, + }); + + return this._http.getUrl(path, { + session: authSessionId, + }, httpApi.PREFIX_R0); +}; + +// Room operations +// =============== + +/** + * Create a new room. + * @param {Object} options a list of options to pass to the /createRoom API. + * @param {string} options.room_alias_name The alias localpart to assign to + * this room. + * @param {string} options.visibility Either 'public' or 'private'. + * @param {string[]} options.invite A list of user IDs to invite to this room. + * @param {string} options.name The name to give this room. + * @param {string} options.topic The topic to give this room. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: {room_id: {string}, + * room_alias: {string(opt)}} + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.createRoom = function(options, callback) { + // valid options include: room_alias_name, visibility, invite + return this._http.authedRequest( + callback, "POST", "/createRoom", undefined, options, + ); +}; +/** + * Fetches relations for a given event + * @param {string} roomId the room of the event + * @param {string} eventId the id of the event + * @param {string} relationType the rel_type of the relations requested + * @param {string} eventType the event type of the relations requested + * @param {Object} opts options with optional values for the request. + * @param {Object} opts.from the pagination token returned from a previous request as `next_batch` to return following relations. + * @return {Object} the response, with chunk and next_batch. + */ +MatrixBaseApis.prototype.fetchRelations = + async function(roomId, eventId, relationType, eventType, opts) { + const queryParams = {}; + if (opts.from) { + queryParams.from = opts.from; + } + const queryString = utils.encodeParams(queryParams); + const path = utils.encodeUri( + "/rooms/$roomId/relations/$eventId/$relationType/$eventType?" + queryString, { + $roomId: roomId, + $eventId: eventId, + $relationType: relationType, + $eventType: eventType, + }); + const response = await this._http.authedRequestWithPrefix( + undefined, "GET", path, null, null, httpApi.PREFIX_UNSTABLE, + ); + return response; +}; + +/** + * @param {string} roomId + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.roomState = function(roomId, callback) { + const path = utils.encodeUri("/rooms/$roomId/state", {$roomId: roomId}); + return this._http.authedRequest(callback, "GET", path); +}; + +/** + * Get an event in a room by its event id. + * @param {string} roomId + * @param {string} eventId + * @param {module:client.callback} callback Optional. + * + * @return {Promise} Resolves to an object containing the event. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.fetchRoomEvent = function(roomId, eventId, callback) { + const path = utils.encodeUri( + "/rooms/$roomId/event/$eventId", { + $roomId: roomId, + $eventId: eventId, + }, + ); + return this._http.authedRequest(callback, "GET", path); +}; + +/** + * @param {string} roomId + * @param {string} includeMembership the membership type to include in the response + * @param {string} excludeMembership the membership type to exclude from the response + * @param {string} atEventId the id of the event for which moment in the timeline the members should be returned for + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: dictionary of userid to profile information + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.members = +function(roomId, includeMembership, excludeMembership, atEventId, callback) { + const queryParams = {}; + if (includeMembership) { + queryParams.membership = includeMembership; + } + if (excludeMembership) { + queryParams.not_membership = excludeMembership; + } + if (atEventId) { + queryParams.at = atEventId; + } + + const queryString = utils.encodeParams(queryParams); + + const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, + {$roomId: roomId}); + return this._http.authedRequest(callback, "GET", path); +}; + +/** + * Upgrades a room to a new protocol version + * @param {string} roomId + * @param {string} newVersion The target version to upgrade to + * @return {module:client.Promise} Resolves: Object with key 'replacement_room' + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.upgradeRoom = function(roomId, newVersion) { + const path = utils.encodeUri("/rooms/$roomId/upgrade", {$roomId: roomId}); + return this._http.authedRequest( + undefined, "POST", path, undefined, {new_version: newVersion}, + ); +}; + + +/** + * @param {string} groupId + * @return {module:client.Promise} Resolves: Group summary object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getGroupSummary = function(groupId) { + const path = utils.encodeUri("/groups/$groupId/summary", {$groupId: groupId}); + return this._http.authedRequest(undefined, "GET", path); +}; + +/** + * @param {string} groupId + * @return {module:client.Promise} Resolves: Group profile object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getGroupProfile = function(groupId) { + const path = utils.encodeUri("/groups/$groupId/profile", {$groupId: groupId}); + return this._http.authedRequest(undefined, "GET", path); +}; + +/** + * @param {string} groupId + * @param {Object} profile The group profile object + * @param {string=} profile.name Name of the group + * @param {string=} profile.avatar_url MXC avatar URL + * @param {string=} profile.short_description A short description of the room + * @param {string=} profile.long_description A longer HTML description of the room + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.setGroupProfile = function(groupId, profile) { + const path = utils.encodeUri("/groups/$groupId/profile", {$groupId: groupId}); + return this._http.authedRequest( + undefined, "POST", path, undefined, profile, + ); +}; + +/** + * @param {string} groupId + * @param {object} policy The join policy for the group. Must include at + * least a 'type' field which is 'open' if anyone can join the group + * the group without prior approval, or 'invite' if an invite is + * required to join. + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.setGroupJoinPolicy = function(groupId, policy) { + const path = utils.encodeUri( + "/groups/$groupId/settings/m.join_policy", + {$groupId: groupId}, + ); + return this._http.authedRequest( + undefined, "PUT", path, undefined, { + 'm.join_policy': policy, + }, + ); +}; + +/** + * @param {string} groupId + * @return {module:client.Promise} Resolves: Group users list object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getGroupUsers = function(groupId) { + const path = utils.encodeUri("/groups/$groupId/users", {$groupId: groupId}); + return this._http.authedRequest(undefined, "GET", path); +}; + +/** + * @param {string} groupId + * @return {module:client.Promise} Resolves: Group users list object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getGroupInvitedUsers = function(groupId) { + const path = utils.encodeUri("/groups/$groupId/invited_users", {$groupId: groupId}); + return this._http.authedRequest(undefined, "GET", path); +}; + +/** + * @param {string} groupId + * @return {module:client.Promise} Resolves: Group rooms list object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getGroupRooms = function(groupId) { + const path = utils.encodeUri("/groups/$groupId/rooms", {$groupId: groupId}); + return this._http.authedRequest(undefined, "GET", path); +}; + +/** + * @param {string} groupId + * @param {string} userId + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.inviteUserToGroup = function(groupId, userId) { + const path = utils.encodeUri( + "/groups/$groupId/admin/users/invite/$userId", + {$groupId: groupId, $userId: userId}, + ); + return this._http.authedRequest(undefined, "PUT", path, undefined, {}); +}; + +/** + * @param {string} groupId + * @param {string} userId + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.removeUserFromGroup = function(groupId, userId) { + const path = utils.encodeUri( + "/groups/$groupId/admin/users/remove/$userId", + {$groupId: groupId, $userId: userId}, + ); + return this._http.authedRequest(undefined, "PUT", path, undefined, {}); +}; + +/** + * @param {string} groupId + * @param {string} userId + * @param {string} roleId Optional. + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.addUserToGroupSummary = function(groupId, userId, roleId) { + const path = utils.encodeUri( + roleId ? + "/groups/$groupId/summary/$roleId/users/$userId" : + "/groups/$groupId/summary/users/$userId", + {$groupId: groupId, $roleId: roleId, $userId: userId}, + ); + return this._http.authedRequest(undefined, "PUT", path, undefined, {}); +}; + +/** + * @param {string} groupId + * @param {string} userId + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.removeUserFromGroupSummary = function(groupId, userId) { + const path = utils.encodeUri( + "/groups/$groupId/summary/users/$userId", + {$groupId: groupId, $userId: userId}, + ); + return this._http.authedRequest(undefined, "DELETE", path, undefined, {}); +}; + +/** + * @param {string} groupId + * @param {string} roomId + * @param {string} categoryId Optional. + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.addRoomToGroupSummary = function(groupId, roomId, categoryId) { + const path = utils.encodeUri( + categoryId ? + "/groups/$groupId/summary/$categoryId/rooms/$roomId" : + "/groups/$groupId/summary/rooms/$roomId", + {$groupId: groupId, $categoryId: categoryId, $roomId: roomId}, + ); + return this._http.authedRequest(undefined, "PUT", path, undefined, {}); +}; + +/** + * @param {string} groupId + * @param {string} roomId + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.removeRoomFromGroupSummary = function(groupId, roomId) { + const path = utils.encodeUri( + "/groups/$groupId/summary/rooms/$roomId", + {$groupId: groupId, $roomId: roomId}, + ); + return this._http.authedRequest(undefined, "DELETE", path, undefined, {}); +}; + +/** + * @param {string} groupId + * @param {string} roomId + * @param {bool} isPublic Whether the room-group association is visible to non-members + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.addRoomToGroup = function(groupId, roomId, isPublic) { + if (isPublic === undefined) { + isPublic = true; + } + const path = utils.encodeUri( + "/groups/$groupId/admin/rooms/$roomId", + {$groupId: groupId, $roomId: roomId}, + ); + return this._http.authedRequest(undefined, "PUT", path, undefined, + { "m.visibility": { type: isPublic ? "public" : "private" } }, + ); +}; + +/** + * Configure the visibility of a room-group association. + * @param {string} groupId + * @param {string} roomId + * @param {bool} isPublic Whether the room-group association is visible to non-members + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.updateGroupRoomVisibility = function(groupId, roomId, isPublic) { + // NB: The /config API is generic but there's not much point in exposing this yet as synapse + // is the only server to implement this. In future we should consider an API that allows + // arbitrary configuration, i.e. "config/$configKey". + + const path = utils.encodeUri( + "/groups/$groupId/admin/rooms/$roomId/config/m.visibility", + {$groupId: groupId, $roomId: roomId}, + ); + return this._http.authedRequest(undefined, "PUT", path, undefined, + { type: isPublic ? "public" : "private" }, + ); +}; + +/** + * @param {string} groupId + * @param {string} roomId + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.removeRoomFromGroup = function(groupId, roomId) { + const path = utils.encodeUri( + "/groups/$groupId/admin/rooms/$roomId", + {$groupId: groupId, $roomId: roomId}, + ); + return this._http.authedRequest(undefined, "DELETE", path, undefined, {}); +}; + +/** + * @param {string} groupId + * @param {Object} opts Additional options to send alongside the acceptance. + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.acceptGroupInvite = function(groupId, opts = null) { + const path = utils.encodeUri( + "/groups/$groupId/self/accept_invite", + {$groupId: groupId}, + ); + return this._http.authedRequest(undefined, "PUT", path, undefined, opts || {}); +}; + +/** + * @param {string} groupId + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.joinGroup = function(groupId) { + const path = utils.encodeUri( + "/groups/$groupId/self/join", + {$groupId: groupId}, + ); + return this._http.authedRequest(undefined, "PUT", path, undefined, {}); +}; + +/** + * @param {string} groupId + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.leaveGroup = function(groupId) { + const path = utils.encodeUri( + "/groups/$groupId/self/leave", + {$groupId: groupId}, + ); + return this._http.authedRequest(undefined, "PUT", path, undefined, {}); +}; + +/** + * @return {module:client.Promise} Resolves: The groups to which the user is joined + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getJoinedGroups = function() { + const path = utils.encodeUri("/joined_groups"); + return this._http.authedRequest(undefined, "GET", path); +}; + +/** + * @param {Object} content Request content + * @param {string} content.localpart The local part of the desired group ID + * @param {Object} content.profile Group profile object + * @return {module:client.Promise} Resolves: Object with key group_id: id of the created group + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.createGroup = function(content) { + const path = utils.encodeUri("/create_group"); + return this._http.authedRequest( + undefined, "POST", path, undefined, content, + ); +}; + +/** + * @param {string[]} userIds List of user IDs + * @return {module:client.Promise} Resolves: Object as exmaple below + * + * { + * "users": { + * "@bob:example.com": { + * "+example:example.com" + * } + * } + * } + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getPublicisedGroups = function(userIds) { + // :TCHAP remove obsolete feature + // const path = utils.encodeUri("/publicised_groups"); + // return this._http.authedRequest( + // undefined, "POST", path, undefined, { user_ids: userIds }, + // ); + return Promise.resolve({"users": {}}); +}; + +/** + * @param {string} groupId + * @param {bool} isPublic Whether the user's membership of this group is made public + * @return {module:client.Promise} Resolves: Empty object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.setGroupPublicity = function(groupId, isPublic) { + const path = utils.encodeUri( + "/groups/$groupId/self/update_publicity", + {$groupId: groupId}, + ); + return this._http.authedRequest(undefined, "PUT", path, undefined, { + publicise: isPublic, + }); +}; + +/** + * Retrieve a state event. + * @param {string} roomId + * @param {string} eventType + * @param {string} stateKey + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getStateEvent = function(roomId, eventType, stateKey, callback) { + const pathParams = { + $roomId: roomId, + $eventType: eventType, + $stateKey: stateKey, + }; + let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams); + if (stateKey !== undefined) { + path = utils.encodeUri(path + "/$stateKey", pathParams); + } + return this._http.authedRequest( + callback, "GET", path, + ); +}; + +/** + * @param {string} roomId + * @param {string} eventType + * @param {Object} content + * @param {string} stateKey + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.sendStateEvent = function(roomId, eventType, content, stateKey, + callback) { + const pathParams = { + $roomId: roomId, + $eventType: eventType, + $stateKey: stateKey, + }; + let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams); + if (stateKey !== undefined) { + path = utils.encodeUri(path + "/$stateKey", pathParams); + } + return this._http.authedRequest( + callback, "PUT", path, undefined, content, + ); +}; + +/** + * @param {string} roomId + * @param {Number} limit + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.roomInitialSync = function(roomId, limit, callback) { + if (utils.isFunction(limit)) { + callback = limit; limit = undefined; + } + const path = utils.encodeUri("/rooms/$roomId/initialSync", + {$roomId: roomId}, + ); + if (!limit) { + limit = 30; + } + return this._http.authedRequest( + callback, "GET", path, { limit: limit }, + ); +}; + +/** + * Set a marker to indicate the point in a room before which the user has read every + * event. This can be retrieved from room account data (the event type is `m.fully_read`) + * and displayed as a horizontal line in the timeline that is visually distinct to the + * position of the user's own read receipt. + * @param {string} roomId ID of the room that has been read + * @param {string} rmEventId ID of the event that has been read + * @param {string} rrEventId ID of the event tracked by the read receipt. This is here + * for convenience because the RR and the RM are commonly updated at the same time as + * each other. Optional. + * @return {module:client.Promise} Resolves: the empty object, {}. + */ +MatrixBaseApis.prototype.setRoomReadMarkersHttpRequest = + function(roomId, rmEventId, rrEventId) { + const path = utils.encodeUri("/rooms/$roomId/read_markers", { + $roomId: roomId, + }); + + const content = { + "m.fully_read": rmEventId, + "m.read": rrEventId, + }; + + return this._http.authedRequest( + undefined, "POST", path, undefined, content, + ); +}; + +/** + * @return {module:client.Promise} Resolves: A list of the user's current rooms + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getJoinedRooms = function() { + const path = utils.encodeUri("/joined_rooms"); + return this._http.authedRequest(undefined, "GET", path); +}; + +/** + * Retrieve membership info. for a room. + * @param {string} roomId ID of the room to get membership for + * @return {module:client.Promise} Resolves: A list of currently joined users + * and their profile data. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getJoinedRoomMembers = function(roomId) { + const path = utils.encodeUri("/rooms/$roomId/joined_members", { + $roomId: roomId, + }); + return this._http.authedRequest(undefined, "GET", path); +}; + +// Room Directory operations +// ========================= + +/** + * @param {Object} options Options for this request + * @param {string} options.server The remote server to query for the room list. + * Optional. If unspecified, get the local home + * server's public room list. + * @param {number} options.limit Maximum number of entries to return + * @param {string} options.since Token to paginate from + * @param {object} options.filter Filter parameters + * @param {string} options.filter.generic_search_term String to search for + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.publicRooms = function(options, callback) { + if (typeof(options) == 'function') { + callback = options; + options = {}; + } + if (options === undefined) { + options = {}; + } + + const query_params = {}; + if (options.server) { + query_params.server = options.server; + delete options.server; + } + + if (Object.keys(options).length === 0 && Object.keys(query_params).length === 0) { + return this._http.authedRequest(callback, "GET", "/publicRooms"); + } else { + return this._http.authedRequest( + callback, "POST", "/publicRooms", query_params, options, + ); + } +}; + +/** + * Create an alias to room ID mapping. + * @param {string} alias The room alias to create. + * @param {string} roomId The room ID to link the alias to. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.createAlias = function(alias, roomId, callback) { + const path = utils.encodeUri("/directory/room/$alias", { + $alias: alias, + }); + const data = { + room_id: roomId, + }; + return this._http.authedRequest( + callback, "PUT", path, undefined, data, + ); +}; + +/** + * Delete an alias to room ID mapping. This alias must be on your local server + * and you must have sufficient access to do this operation. + * @param {string} alias The room alias to delete. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.deleteAlias = function(alias, callback) { + const path = utils.encodeUri("/directory/room/$alias", { + $alias: alias, + }); + return this._http.authedRequest( + callback, "DELETE", path, undefined, undefined, + ); +}; + +/** + * Get room info for the given alias. + * @param {string} alias The room alias to resolve. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: Object with room_id and servers. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getRoomIdForAlias = function(alias, callback) { + // TODO: deprecate this or resolveRoomAlias + const path = utils.encodeUri("/directory/room/$alias", { + $alias: alias, + }); + return this._http.authedRequest( + callback, "GET", path, + ); +}; + +/** + * @param {string} roomAlias + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.resolveRoomAlias = function(roomAlias, callback) { + // TODO: deprecate this or getRoomIdForAlias + const path = utils.encodeUri("/directory/room/$alias", {$alias: roomAlias}); + return this._http.request(callback, "GET", path); +}; + +/** + * Get the visibility of a room in the current HS's room directory + * @param {string} roomId + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getRoomDirectoryVisibility = + function(roomId, callback) { + const path = utils.encodeUri("/directory/list/room/$roomId", { + $roomId: roomId, + }); + return this._http.authedRequest(callback, "GET", path); +}; + +/** + * Set the visbility of a room in the current HS's room directory + * @param {string} roomId + * @param {string} visibility "public" to make the room visible + * in the public directory, or "private" to make + * it invisible. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.setRoomDirectoryVisibility = + function(roomId, visibility, callback) { + const path = utils.encodeUri("/directory/list/room/$roomId", { + $roomId: roomId, + }); + return this._http.authedRequest( + callback, "PUT", path, undefined, { "visibility": visibility }, + ); +}; + +/** + * Set the visbility of a room bridged to a 3rd party network in + * the current HS's room directory. + * @param {string} networkId the network ID of the 3rd party + * instance under which this room is published under. + * @param {string} roomId + * @param {string} visibility "public" to make the room visible + * in the public directory, or "private" to make + * it invisible. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.setRoomDirectoryVisibilityAppService = + function(networkId, roomId, visibility, callback) { + const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", { + $networkId: networkId, + $roomId: roomId, + }); + return this._http.authedRequest( + callback, "PUT", path, undefined, { "visibility": visibility }, + ); +}; + +// User Directory Operations +// ========================= + +/** + * Query the user directory with a term matching user IDs, display names and domains. + * @param {object} opts options + * @param {string} opts.term the term with which to search. + * @param {number} opts.limit the maximum number of results to return. The server will + * apply a limit if unspecified. + * @return {module:client.Promise} Resolves: an array of results. + */ +MatrixBaseApis.prototype.searchUserDirectory = function(opts) { + const body = { + search_term: opts.term, + }; + + if (opts.limit !== undefined) { + body.limit = opts.limit; + } + + return this._http.authedRequest( + undefined, "POST", "/user_directory/search", undefined, body, + ); +}; + + +// Media operations +// ================ + +/** + * Upload a file to the media repository on the home server. + * + * @param {object} file The object to upload. On a browser, something that + * can be sent to XMLHttpRequest.send (typically a File). Under node.js, + * a a Buffer, String or ReadStream. + * + * @param {object} opts options object + * + * @param {string=} opts.name Name to give the file on the server. Defaults + * to file.name. + * + * @param {boolean=} opts.includeFilename if false will not send the filename, + * e.g for encrypted file uploads where filename leaks are undesirable. + * Defaults to true. + * + * @param {string=} opts.type Content-type for the upload. Defaults to + * file.type, or applicaton/octet-stream. + * + * @param {boolean=} opts.rawResponse Return the raw body, rather than + * parsing the JSON. Defaults to false (except on node.js, where it + * defaults to true for backwards compatibility). + * + * @param {boolean=} opts.onlyContentUri Just return the content URI, + * rather than the whole body. Defaults to false (except on browsers, + * where it defaults to true for backwards compatibility). Ignored if + * opts.rawResponse is true. + * + * @param {Function=} opts.callback Deprecated. Optional. The callback to + * invoke on success/failure. See the promise return values for more + * information. + * + * @param {Function=} opts.progressHandler Optional. Called when a chunk of + * data has been uploaded, with an object containing the fields `loaded` + * (number of bytes transferred) and `total` (total size, if known). + * + * @return {module:client.Promise} Resolves to response object, as + * determined by this.opts.onlyData, opts.rawResponse, and + * opts.onlyContentUri. Rejects with an error (usually a MatrixError). + */ +MatrixBaseApis.prototype.uploadContent = function(file, opts) { + return this._http.uploadContent(file, opts); +}; + +/** + * Cancel a file upload in progress + * @param {module:client.Promise} promise The promise returned from uploadContent + * @return {boolean} true if canceled, otherwise false + */ +MatrixBaseApis.prototype.cancelUpload = function(promise) { + return this._http.cancelUpload(promise); +}; + +/** + * Get a list of all file uploads in progress + * @return {array} Array of objects representing current uploads. + * Currently in progress is element 0. Keys: + * - promise: The promise associated with the upload + * - loaded: Number of bytes uploaded + * - total: Total number of bytes to upload + */ +MatrixBaseApis.prototype.getCurrentUploads = function() { + return this._http.getCurrentUploads(); +}; + + +// Profile operations +// ================== + +/** + * @param {string} userId + * @param {string} info The kind of info to retrieve (e.g. 'displayname', + * 'avatar_url'). + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getProfileInfo = function(userId, info, callback) { + if (utils.isFunction(info)) { + callback = info; info = undefined; + } + + const path = info ? + utils.encodeUri("/profile/$userId/$info", + { $userId: userId, $info: info }) : + utils.encodeUri("/profile/$userId", + { $userId: userId }); + return this._http.authedRequest(callback, "GET", path); +}; + + +// Account operations +// ================== + +/** + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getThreePids = function(callback) { + const path = "/account/3pid"; + return this._http.authedRequest( + callback, "GET", path, undefined, undefined, + ); +}; + +/** + * @param {Object} creds + * @param {boolean} bind + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.addThreePid = function(creds, bind, callback) { + const path = "/account/3pid"; + const data = { + 'threePidCreds': creds, + 'bind': bind, + }; + return this._http.authedRequest( + callback, "POST", path, null, data, + ); +}; + +/** + * @param {string} medium The threepid medium (eg. 'email') + * @param {string} address The threepid address (eg. 'bob@example.com') + * this must be as returned by getThreePids. + * @return {module:client.Promise} Resolves: The server response on success + * (generally the empty JSON object) + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.deleteThreePid = function(medium, address) { + const path = "/account/3pid/delete"; + const data = { + 'medium': medium, + 'address': address, + }; + return this._http.authedRequest(undefined, "POST", path, null, data); +}; + +/** + * Make a request to change your password. + * @param {Object} authDict + * @param {string} newPassword The new desired password. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.setPassword = function(authDict, newPassword, callback) { + const path = "/account/password"; + const data = { + 'auth': authDict, + 'new_password': newPassword, + }; + + return this._http.authedRequest( + callback, "POST", path, null, data, + ); +}; + + +// Device operations +// ================= + +/** + * Gets all devices recorded for the logged-in user + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getDevices = function() { + return this._http.authedRequest( + undefined, 'GET', "/devices", undefined, undefined, + ); +}; + +/** + * Update the given device + * + * @param {string} device_id device to update + * @param {Object} body body of request + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.setDeviceDetails = function(device_id, body) { + const path = utils.encodeUri("/devices/$device_id", { + $device_id: device_id, + }); + + return this._http.authedRequest(undefined, "PUT", path, undefined, body); +}; + +/** + * Delete the given device + * + * @param {string} device_id device to delete + * @param {object} auth Optional. Auth data to supply for User-Interactive auth. + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.deleteDevice = function(device_id, auth) { + const path = utils.encodeUri("/devices/$device_id", { + $device_id: device_id, + }); + + const body = {}; + + if (auth) { + body.auth = auth; + } + + return this._http.authedRequest(undefined, "DELETE", path, undefined, body); +}; + +/** + * Delete multiple device + * + * @param {string[]} devices IDs of the devices to delete + * @param {object} auth Optional. Auth data to supply for User-Interactive auth. + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.deleteMultipleDevices = function(devices, auth) { + const body = {devices}; + + if (auth) { + body.auth = auth; + } + + const path = "/delete_devices"; + return this._http.authedRequest(undefined, "POST", path, undefined, body); +}; + + +// Push operations +// =============== + +/** + * Gets all pushers registered for the logged-in user + * + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: Array of objects representing pushers + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getPushers = function(callback) { + const path = "/pushers"; + return this._http.authedRequest( + callback, "GET", path, undefined, undefined, + ); +}; + +/** + * Adds a new pusher or updates an existing pusher + * + * @param {Object} pusher Object representing a pusher + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: Empty json object on success + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.setPusher = function(pusher, callback) { + const path = "/pushers/set"; + return this._http.authedRequest( + callback, "POST", path, null, pusher, + ); +}; + +/** + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getPushRules = function(callback) { + return this._http.authedRequest(callback, "GET", "/pushrules/"); +}; + +/** + * @param {string} scope + * @param {string} kind + * @param {string} ruleId + * @param {Object} body + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.addPushRule = function(scope, kind, ruleId, body, callback) { + // NB. Scope not uri encoded because devices need the '/' + const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { + $kind: kind, + $ruleId: ruleId, + }); + return this._http.authedRequest( + callback, "PUT", path, undefined, body, + ); +}; + +/** + * @param {string} scope + * @param {string} kind + * @param {string} ruleId + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.deletePushRule = function(scope, kind, ruleId, callback) { + // NB. Scope not uri encoded because devices need the '/' + const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { + $kind: kind, + $ruleId: ruleId, + }); + return this._http.authedRequest(callback, "DELETE", path); +}; + +/** + * Enable or disable a push notification rule. + * @param {string} scope + * @param {string} kind + * @param {string} ruleId + * @param {boolean} enabled + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.setPushRuleEnabled = function(scope, kind, + ruleId, enabled, callback) { + const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/enabled", { + $kind: kind, + $ruleId: ruleId, + }); + return this._http.authedRequest( + callback, "PUT", path, undefined, {"enabled": enabled}, + ); +}; + +/** + * Set the actions for a push notification rule. + * @param {string} scope + * @param {string} kind + * @param {string} ruleId + * @param {array} actions + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.setPushRuleActions = function(scope, kind, + ruleId, actions, callback) { + const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/actions", { + $kind: kind, + $ruleId: ruleId, + }); + return this._http.authedRequest( + callback, "PUT", path, undefined, {"actions": actions}, + ); +}; + + +// Search +// ====== + +/** + * Perform a server-side search. + * @param {Object} opts + * @param {string} opts.next_batch the batch token to pass in the query string + * @param {Object} opts.body the JSON object to pass to the request body. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.search = function(opts, callback) { + const queryparams = {}; + if (opts.next_batch) { + queryparams.next_batch = opts.next_batch; + } + return this._http.authedRequest( + callback, "POST", "/search", queryparams, opts.body, + ); +}; + +// Crypto +// ====== + +/** + * Upload keys + * + * @param {Object} content body of upload request + * + * @param {Object=} opts + * + * @param {string=} opts.device_id explicit device_id to use for upload + * (default is to use the same as that used during auth). + * + * @param {module:client.callback=} callback + * + * @return {module:client.Promise} Resolves: result object. Rejects: with + * an error response ({@link module:http-api.MatrixError}). + */ +MatrixBaseApis.prototype.uploadKeysRequest = function(content, opts, callback) { + opts = opts || {}; + const deviceId = opts.device_id; + let path; + if (deviceId) { + path = utils.encodeUri("/keys/upload/$deviceId", { + $deviceId: deviceId, + }); + } else { + path = "/keys/upload"; + } + return this._http.authedRequest(callback, "POST", path, undefined, content); +}; + +/** + * Download device keys + * + * @param {string[]} userIds list of users to get keys for + * + * @param {Object=} opts + * + * @param {string=} opts.token sync token to pass in the query request, to help + * the HS give the most recent results + * + * @return {module:client.Promise} Resolves: result object. Rejects: with + * an error response ({@link module:http-api.MatrixError}). + */ +MatrixBaseApis.prototype.downloadKeysForUsers = function(userIds, opts) { + if (utils.isFunction(opts)) { + // opts used to be 'callback'. + throw new Error( + 'downloadKeysForUsers no longer accepts a callback parameter', + ); + } + opts = opts || {}; + + const content = { + device_keys: {}, + }; + if ('token' in opts) { + content.token = opts.token; + } + userIds.forEach((u) => { + content.device_keys[u] = []; + }); + + return this._http.authedRequest(undefined, "POST", "/keys/query", undefined, content); +}; + +/** + * Claim one-time keys + * + * @param {string[]} devices a list of [userId, deviceId] pairs + * + * @param {string} [key_algorithm = signed_curve25519] desired key type + * + * @return {module:client.Promise} Resolves: result object. Rejects: with + * an error response ({@link module:http-api.MatrixError}). + */ +MatrixBaseApis.prototype.claimOneTimeKeys = function(devices, key_algorithm) { + const queries = {}; + + if (key_algorithm === undefined) { + key_algorithm = "signed_curve25519"; + } + + for (let i = 0; i < devices.length; ++i) { + const userId = devices[i][0]; + const deviceId = devices[i][1]; + const query = queries[userId] || {}; + queries[userId] = query; + query[deviceId] = key_algorithm; + } + const content = {one_time_keys: queries}; + const path = "/keys/claim"; + return this._http.authedRequest(undefined, "POST", path, undefined, content); +}; + +/** + * Ask the server for a list of users who have changed their device lists + * between a pair of sync tokens + * + * @param {string} oldToken + * @param {string} newToken + * + * @return {module:client.Promise} Resolves: result object. Rejects: with + * an error response ({@link module:http-api.MatrixError}). + */ +MatrixBaseApis.prototype.getKeyChanges = function(oldToken, newToken) { + const qps = { + from: oldToken, + to: newToken, + }; + + const path = "/keys/changes"; + return this._http.authedRequest(undefined, "GET", path, qps, undefined); +}; + + +// Identity Server Operations +// ========================== + +/** + * Requests an email verification token directly from an Identity Server. + * + * Note that the Home Server offers APIs to proxy this API for specific + * situations, allowing for better feedback to the user. + * + * @param {string} email The email address to request a token for + * @param {string} clientSecret A secret binary string generated by the client. + * It is recommended this be around 16 ASCII characters. + * @param {number} sendAttempt If an identity server sees a duplicate request + * with the same sendAttempt, it will not send another email. + * To request another email to be sent, use a larger value for + * the sendAttempt param as was used in the previous request. + * @param {string} nextLink Optional If specified, the client will be redirected + * to this link after validation. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + * @throws Error if No ID server is set + */ +MatrixBaseApis.prototype.requestEmailToken = function(email, clientSecret, + sendAttempt, nextLink, callback) { + const params = { + client_secret: clientSecret, + email: email, + send_attempt: sendAttempt, + next_link: nextLink, + }; + return this._http.idServerRequest( + callback, "POST", "/validate/email/requestToken", + params, httpApi.PREFIX_IDENTITY_V1, + ); +}; + +/** + * Submits an MSISDN token to the identity server + * + * This is used when submitting the code sent by SMS to a phone number. + * The ID server has an equivalent API for email but the js-sdk does + * not expose this, since email is normally validated by the user clicking + * a link rather than entering a code. + * + * @param {string} sid The sid given in the response to requestToken + * @param {string} clientSecret A secret binary string generated by the client. + * This must be the same value submitted in the requestToken call. + * @param {string} token The token, as enetered by the user. + * + * @return {module:client.Promise} Resolves: Object, currently with no parameters. + * @return {module:http-api.MatrixError} Rejects: with an error response. + * @throws Error if No ID server is set + */ +MatrixBaseApis.prototype.submitMsisdnToken = function(sid, clientSecret, token) { + const params = { + sid: sid, + client_secret: clientSecret, + token: token, + }; + return this._http.idServerRequest( + undefined, "POST", "/validate/msisdn/submitToken", + params, httpApi.PREFIX_IDENTITY_V1, + ); +}; + +/** + * Looks up the public Matrix ID mapping for a given 3rd party + * identifier from the Identity Server + * @param {string} medium The medium of the threepid, eg. 'email' + * @param {string} address The textual address of the threepid + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: A threepid mapping + * object or the empty object if no mapping + * exists + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.lookupThreePid = function(medium, address, callback) { + const params = { + medium: medium, + address: address, + }; + return this._http.idServerRequest( + callback, "GET", "/lookup", + params, httpApi.PREFIX_IDENTITY_V1, + ); +}; + + +// Direct-to-device messaging +// ========================== + +/** + * Send an event to a specific list of devices + * + * @param {string} eventType type of event to send + * @param {Object.>} contentMap + * content to send. Map from user_id to device_id to content object. + * @param {string=} txnId transaction id. One will be made up if not + * supplied. + * @return {module:client.Promise} Resolves to the result object + */ +MatrixBaseApis.prototype.sendToDevice = function( + eventType, contentMap, txnId, +) { + const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", { + $eventType: eventType, + $txnId: txnId ? txnId : this.makeTxnId(), + }); + + const body = { + messages: contentMap, + }; + + return this._http.authedRequest(undefined, "PUT", path, undefined, body); +}; + +// Third party Lookup API +// ====================== + +/** + * Get the third party protocols that can be reached using + * this HS + * @return {module:client.Promise} Resolves to the result object + */ +MatrixBaseApis.prototype.getThirdpartyProtocols = function() { + return this._http.authedRequest( + undefined, "GET", "/thirdparty/protocols", undefined, undefined, + ).then((response) => { + // sanity check + if (!response || typeof(response) !== 'object') { + throw new Error( + `/thirdparty/protocols did not return an object: ${response}`, + ); + } + return response; + }); +}; + +/** + * Get information on how a specific place on a third party protocol + * may be reached. + * @param {string} protocol The protocol given in getThirdpartyProtocols() + * @param {object} params Protocol-specific parameters, as given in the + * response to getThirdpartyProtocols() + * @return {module:client.Promise} Resolves to the result object + */ +MatrixBaseApis.prototype.getThirdpartyLocation = function(protocol, params) { + const path = utils.encodeUri("/thirdparty/location/$protocol", { + $protocol: protocol, + }); + + return this._http.authedRequest(undefined, "GET", path, params, undefined); +}; + +/** + * Get information on how a specific user on a third party protocol + * may be reached. + * @param {string} protocol The protocol given in getThirdpartyProtocols() + * @param {object} params Protocol-specific parameters, as given in the + * response to getThirdpartyProtocols() + * @return {module:client.Promise} Resolves to the result object + */ +MatrixBaseApis.prototype.getThirdpartyUser = function(protocol, params) { + const path = utils.encodeUri("/thirdparty/user/$protocol", { + $protocol: protocol, + }); + + return this._http.authedRequest(undefined, "GET", path, params, undefined); +}; + +/** + * MatrixBaseApis object + */ +module.exports = MatrixBaseApis; diff --git a/matrix-js-sdk/src/client.js b/matrix-js-sdk/src/client.js new file mode 100644 index 000000000..df673885c --- /dev/null +++ b/matrix-js-sdk/src/client.js @@ -0,0 +1,4772 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations 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. +*/ +"use strict"; + +const PushProcessor = require('./pushprocessor'); + +/** + * This is an internal module. See {@link MatrixClient} for the public class. + * @module client + */ +const EventEmitter = require("events").EventEmitter; +import Promise from 'bluebird'; +const url = require('url'); + +const httpApi = require("./http-api"); +const MatrixEvent = require("./models/event").MatrixEvent; +const EventStatus = require("./models/event").EventStatus; +const EventTimeline = require("./models/event-timeline"); +const SearchResult = require("./models/search-result"); +const StubStore = require("./store/stub"); +const webRtcCall = require("./webrtc/call"); +const utils = require("./utils"); +const contentRepo = require("./content-repo"); +const Filter = require("./filter"); +const SyncApi = require("./sync"); +const MatrixBaseApis = require("./base-apis"); +const MatrixError = httpApi.MatrixError; +const ContentHelpers = require("./content-helpers"); +const olmlib = require("./crypto/olmlib"); + +import ReEmitter from './ReEmitter'; +import RoomList from './crypto/RoomList'; +import logger from '../src/logger'; + +import Crypto from './crypto'; +import { isCryptoAvailable } from './crypto'; +import { encodeRecoveryKey, decodeRecoveryKey } from './crypto/recoverykey'; +import { keyForNewBackup, keyForExistingBackup } from './crypto/backup_password'; +import { randomString } from './randomstring'; + +// Disable warnings for now: we use deprecated bluebird functions +// and need to migrate, but they spam the console with warnings. +Promise.config({warnings: false}); + + +const SCROLLBACK_DELAY_MS = 3000; +const CRYPTO_ENABLED = isCryptoAvailable(); +const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value + +function keysFromRecoverySession(sessions, decryptionKey, roomId) { + const keys = []; + for (const [sessionId, sessionData] of Object.entries(sessions)) { + try { + const decrypted = keyFromRecoverySession(sessionData, decryptionKey); + decrypted.session_id = sessionId; + decrypted.room_id = roomId; + keys.push(decrypted); + } catch (e) { + logger.log("Failed to decrypt session from backup"); + } + } + return keys; +} + +function keyFromRecoverySession(session, decryptionKey) { + return JSON.parse(decryptionKey.decrypt( + session.session_data.ephemeral, + session.session_data.mac, + session.session_data.ciphertext, + )); +} + +/** + * Construct a Matrix Client. Only directly construct this if you want to use + * custom modules. Normally, {@link createClient} should be used + * as it specifies 'sensible' defaults for these modules. + * @constructor + * @extends {external:EventEmitter} + * @extends {module:base-apis~MatrixBaseApis} + * + * @param {Object} opts The configuration options for this client. + * @param {string} opts.baseUrl Required. The base URL to the client-server + * HTTP API. + * @param {string} opts.idBaseUrl Optional. The base identity server URL for + * identity server requests. + * @param {Function} opts.request Required. The function to invoke for HTTP + * requests. The value of this property is typically require("request") + * as it returns a function which meets the required interface. See + * {@link requestFunction} for more information. + * + * @param {string} opts.accessToken The access_token for this user. + * + * @param {string} opts.userId The user ID for this user. + * + * @param {Object=} opts.store + * The data store used for sync data from the homeserver. If not specified, + * this client will not store any HTTP responses. The `createClient` helper + * will create a default store if needed. + * + * @param {module:store/session/webstorage~WebStorageSessionStore} opts.sessionStore + * A store to be used for end-to-end crypto session data. Most data has been + * migrated out of here to `cryptoStore` instead. If not specified, + * end-to-end crypto will be disabled. The `createClient` helper + * _will not_ create this store at the moment. + * + * @param {module:crypto.store.base~CryptoStore} opts.cryptoStore + * A store to be used for end-to-end crypto session data. If not specified, + * end-to-end crypto will be disabled. The `createClient` helper will create + * a default store if needed. + * + * @param {string=} opts.deviceId A unique identifier for this device; used for + * tracking things like crypto keys and access tokens. If not specified, + * end-to-end crypto will be disabled. + * + * @param {Object} opts.scheduler Optional. The scheduler to use. If not + * specified, this client will not retry requests on failure. This client + * will supply its own processing function to + * {@link module:scheduler~MatrixScheduler#setProcessFunction}. + * + * @param {Object} opts.queryParams Optional. Extra query parameters to append + * to all requests with this client. Useful for application services which require + * ?user_id=. + * + * @param {Number=} opts.localTimeoutMs Optional. The default maximum amount of + * time to wait before timing out HTTP requests. If not specified, there is no timeout. + * + * @param {boolean} [opts.useAuthorizationHeader = false] Set to true to use + * Authorization header instead of query param to send the access token to the server. + * + * @param {boolean} [opts.timelineSupport = false] Set to true to enable + * improved timeline support ({@link + * module:client~MatrixClient#getEventTimeline getEventTimeline}). It is + * disabled by default for compatibility with older clients - in particular to + * maintain support for back-paginating the live timeline after a '/sync' + * result with a gap. + * + * @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. + * + * @param {Array} [opts.verificationMethods] Optional. The verification method + * that the application can handle. Each element should be an item from {@link + * module:crypto~verificationMethods verificationMethods}, or a class that + * implements the {$link module:crypto/verification/Base verifier interface}. + */ +function MatrixClient(opts) { + // Allow trailing slash in HS url + if (opts.baseUrl && opts.baseUrl.endsWith("/")) { + opts.baseUrl = opts.baseUrl.substr(0, opts.baseUrl.length - 1); + } + + // Allow trailing slash in IS url + if (opts.idBaseUrl && opts.idBaseUrl.endsWith("/")) { + opts.idBaseUrl = opts.idBaseUrl.substr(0, opts.idBaseUrl.length - 1); + } + + MatrixBaseApis.call(this, opts); + + this.olmVersion = null; // Populated after initCrypto is done + + this.reEmitter = new ReEmitter(this); + + this.store = opts.store || new StubStore(); + + this.deviceId = opts.deviceId || null; + + const userId = (opts.userId || null); + this.credentials = { + userId: userId, + }; + + this.scheduler = opts.scheduler; + if (this.scheduler) { + const self = this; + this.scheduler.setProcessFunction(function(eventToSend) { + const room = self.getRoom(eventToSend.getRoomId()); + if (eventToSend.status !== EventStatus.SENDING) { + _updatePendingEventStatus(room, eventToSend, + EventStatus.SENDING); + } + return _sendEventHttpRequest(self, eventToSend); + }); + } + this.clientRunning = false; + + this.callList = { + // callId: MatrixCall + }; + + // try constructing a MatrixCall to see if we are running in an environment + // which has WebRTC. If we are, listen for and handle m.call.* events. + const call = webRtcCall.createNewMatrixCall(this); + this._supportsVoip = false; + if (call) { + setupCallEventHandler(this); + this._supportsVoip = true; + } + this._syncingRetry = null; + this._syncApi = null; + this._peekSync = null; + this._isGuest = false; + this._ongoingScrollbacks = {}; + this.timelineSupport = Boolean(opts.timelineSupport); + this.urlPreviewCache = {}; + this._notifTimelineSet = null; + this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation; + + this._crypto = null; + this._cryptoStore = opts.cryptoStore; + this._sessionStore = opts.sessionStore; + this._verificationMethods = opts.verificationMethods; + + this._forceTURN = opts.forceTURN || false; + + // List of which rooms have encryption enabled: separate from crypto because + // we still want to know which rooms are encrypted even if crypto is disabled: + // we don't want to start sending unencrypted events to them. + this._roomList = new RoomList(this._cryptoStore); + + // The pushprocessor caches useful things, so keep one and re-use it + this._pushProcessor = new PushProcessor(this); + + this._serverSupportsLazyLoading = null; + + this._cachedCapabilities = null; // { capabilities: {}, lastUpdated: timestamp } + + // The SDK doesn't really provide a clean way for events to recalculate the push + // actions for themselves, so we have to kinda help them out when they are encrypted. + // We do this so that push rules are correctly executed on events in their decrypted + // state, such as highlights when the user's name is mentioned. + this.on("Event.decrypted", (event) => { + const oldActions = event.getPushActions(); + const actions = this._pushProcessor.actionsForEvent(event); + event.setPushActions(actions); // Might as well while we're here + + const room = this.getRoom(event.getRoomId()); + if (!room) return; + + const currentCount = room.getUnreadNotificationCount("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 + // have encrypted events to avoid other code from resetting 'highlight' to zero. + const oldHighlight = oldActions && oldActions.tweaks + ? !!oldActions.tweaks.highlight : false; + const newHighlight = actions && actions.tweaks + ? !!actions.tweaks.highlight : false; + if (oldHighlight !== newHighlight || currentCount > 0) { + // TODO: Handle mentions received while the client is offline + // See also https://github.com/vector-im/riot-web/issues/9069 + if (!room.hasUserReadEvent(this.getUserId(), event.getId())) { + let newCount = currentCount; + if (newHighlight && !oldHighlight) newCount++; + if (!newHighlight && oldHighlight) newCount--; + room.setUnreadNotificationCount("highlight", newCount); + + // Fix 'Mentions Only' rooms from not having the right badge count + const totalCount = room.getUnreadNotificationCount('total'); + if (totalCount < newCount) { + room.setUnreadNotificationCount('total', newCount); + } + } + } + }); + + // Like above, we have to listen for read receipts from ourselves in order to + // correctly handle notification counts on encrypted rooms. + // This fixes https://github.com/vector-im/riot-web/issues/9421 + this.on("Room.receipt", (event, room) => { + if (room && this.isRoomEncrypted(room.roomId)) { + // Figure out if we've read something or if it's just informational + const content = event.getContent(); + const isSelf = Object.keys(content).filter(eid => { + return Object.keys(content[eid]['m.read']).includes(this.getUserId()); + }).length > 0; + + if (!isSelf) return; + + // Work backwards to determine how many events are unread. We also set + // a limit for how back we'll look to avoid spinning CPU for too long. + // If we hit the limit, we assume the count is unchanged. + const maxHistory = 20; + const events = room.getLiveTimeline().getEvents(); + + let highlightCount = 0; + + for (let i = events.length - 1; i >= 0; i--) { + if (i === events.length - maxHistory) return; // limit reached + + const event = events[i]; + + if (room.hasUserReadEvent(this.getUserId(), event.getId())) { + // If the user has read the event, then the counting is done. + break; + } + + highlightCount += this.getPushActionsForEvent( + event, + ).tweaks.highlight ? 1 : 0; + } + + // Note: we don't need to handle 'total' notifications because the counts + // will come from the server. + room.setUnreadNotificationCount("highlight", highlightCount); + } + }); +} +utils.inherits(MatrixClient, EventEmitter); +utils.extend(MatrixClient.prototype, MatrixBaseApis.prototype); + +/** + * Clear any data out of the persistent stores used by the client. + * + * @returns {Promise} Promise which resolves when the stores have been cleared. + */ +MatrixClient.prototype.clearStores = function() { + if (this._clientRunning) { + throw new Error("Cannot clear stores while client is running"); + } + + const promises = []; + + promises.push(this.store.deleteAllData()); + if (this._cryptoStore) { + promises.push(this._cryptoStore.deleteAllData()); + } + return Promise.all(promises); +}; + +/** + * Get the user-id of the logged-in user + * + * @return {?string} MXID for the logged-in user, or null if not logged in + */ +MatrixClient.prototype.getUserId = function() { + if (this.credentials && this.credentials.userId) { + return this.credentials.userId; + } + return null; +}; + +/** + * Get the domain for this client's MXID + * @return {?string} Domain of this MXID + */ +MatrixClient.prototype.getDomain = function() { + if (this.credentials && this.credentials.userId) { + return this.credentials.userId.replace(/^.*?:/, ''); + } + return null; +}; + +/** + * Get the local part of the current user ID e.g. "foo" in "@foo:bar". + * @return {?string} The user ID localpart or null. + */ +MatrixClient.prototype.getUserIdLocalpart = function() { + if (this.credentials && this.credentials.userId) { + return this.credentials.userId.split(":")[0].substring(1); + } + return null; +}; + +/** + * Get the device ID of this client + * @return {?string} device ID + */ +MatrixClient.prototype.getDeviceId = function() { + return this.deviceId; +}; + + +/** + * Check if the runtime environment supports VoIP calling. + * @return {boolean} True if VoIP is supported. + */ +MatrixClient.prototype.supportsVoip = function() { + return this._supportsVoip; +}; + +/** + * Set whether VoIP calls are forced to use only TURN + * candidates. This is the same as the forceTURN option + * when creating the client. + * @param {bool} forceTURN True to force use of TURN servers + */ +MatrixClient.prototype.setForceTURN = function(forceTURN) { + this._forceTURN = forceTURN; +}; + +/** + * Get the current sync state. + * @return {?string} the sync state, which may be null. + * @see module:client~MatrixClient#event:"sync" + */ +MatrixClient.prototype.getSyncState = function() { + if (!this._syncApi) { + return null; + } + return this._syncApi.getSyncState(); +}; + +/** + * 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} + */ +MatrixClient.prototype.getSyncStateData = function() { + if (!this._syncApi) { + return null; + } + return this._syncApi.getSyncStateData(); +}; + +/** + * Return whether the client is configured for a guest account. + * @return {boolean} True if this is a guest access_token (or no token is supplied). + */ +MatrixClient.prototype.isGuest = function() { + return this._isGuest; +}; + +/** + * Return the provided scheduler, if any. + * @return {?module:scheduler~MatrixScheduler} The scheduler or null + */ +MatrixClient.prototype.getScheduler = function() { + return this.scheduler; +}; + +/** + * Set whether this client is a guest account. This method is experimental + * and may change without warning. + * @param {boolean} isGuest True if this is a guest account. + */ +MatrixClient.prototype.setGuest = function(isGuest) { + // EXPERIMENTAL: + // If the token is a macaroon, it should be encoded in it that it is a 'guest' + // access token, which means that the SDK can determine this entirely without + // the dev manually flipping this flag. + this._isGuest = isGuest; +}; + +/** + * 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. + */ +MatrixClient.prototype.retryImmediately = function() { + return this._syncApi.retryImmediately(); +}; + +/** + * Return the global notification EventTimelineSet, if any + * + * @return {EventTimelineSet} the globl notification EventTimelineSet + */ +MatrixClient.prototype.getNotifTimelineSet = function() { + return this._notifTimelineSet; +}; + +/** + * Set the global notification EventTimelineSet + * + * @param {EventTimelineSet} notifTimelineSet + */ +MatrixClient.prototype.setNotifTimelineSet = function(notifTimelineSet) { + this._notifTimelineSet = notifTimelineSet; +}; + +/** + * Gets the capabilities of the homeserver. Always returns an object of + * capability keys and their options, which may be empty. + * @param {boolean} fresh True to ignore any cached values. + * @return {module:client.Promise} Resolves to the capabilities of the homeserver + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.getCapabilities = function(fresh=false) { + const now = new Date().getTime(); + + if (this._cachedCapabilities && !fresh) { + if (now < this._cachedCapabilities.expiration) { + logger.log("Returning cached capabilities"); + return Promise.resolve(this._cachedCapabilities.capabilities); + } + } + + // We swallow errors because we need a default object anyhow + return this._http.authedRequest( + undefined, "GET", "/capabilities", + ).catch((e) => { + logger.error(e); + return null; // otherwise consume the error + }).then((r) => { + if (!r) r = {}; + const capabilities = r["capabilities"] || {}; + + // If the capabilities missed the cache, cache it for a shorter amount + // of time to try and refresh them later. + const cacheMs = Object.keys(capabilities).length + ? CAPABILITIES_CACHE_MS + : 60000 + (Math.random() * 5000); + + this._cachedCapabilities = { + capabilities: capabilities, + expiration: now + cacheMs, + }; + + logger.log("Caching capabilities: ", capabilities); + return capabilities; + }); +}; + +// Crypto bits +// =========== + +/** + * Initialise support for end-to-end encryption in this client + * + * You should call this method after creating the matrixclient, but *before* + * calling `startClient`, if you want to support end-to-end encryption. + * + * It will return a Promise which will resolve when the crypto layer has been + * successfully initialised. + */ +MatrixClient.prototype.initCrypto = async function() { + if (!isCryptoAvailable()) { + throw new Error( + `End-to-end encryption not supported in this js-sdk build: did ` + + `you remember to load the olm library?`, + ); + } + + if (this._crypto) { + logger.warn("Attempt to re-initialise e2e encryption on MatrixClient"); + return; + } + + if (!this._sessionStore) { + // this is temporary, the sessionstore is supposed to be going away + throw new Error(`Cannot enable encryption: no sessionStore provided`); + } + if (!this._cryptoStore) { + // the cryptostore is provided by sdk.createClient, so this shouldn't happen + throw new Error(`Cannot enable encryption: no cryptoStore provided`); + } + + // initialise the list of encrypted rooms (whether or not crypto is enabled) + logger.log("Crypto: initialising roomlist..."); + await this._roomList.init(); + + const userId = this.getUserId(); + if (userId === null) { + throw new Error( + `Cannot enable encryption on MatrixClient with unknown userId: ` + + `ensure userId is passed in createClient().`, + ); + } + if (this.deviceId === null) { + throw new Error( + `Cannot enable encryption on MatrixClient with unknown deviceId: ` + + `ensure deviceId is passed in createClient().`, + ); + } + + const crypto = new Crypto( + this, + this._sessionStore, + userId, this.deviceId, + this.store, + this._cryptoStore, + this._roomList, + this._verificationMethods, + ); + + this.reEmitter.reEmit(crypto, [ + "crypto.keyBackupFailed", + "crypto.keyBackupSessionsRemaining", + "crypto.roomKeyRequest", + "crypto.roomKeyRequestCancellation", + "crypto.warning", + ]); + + logger.log("Crypto: initialising crypto object..."); + await crypto.init(); + + this.olmVersion = Crypto.getOlmVersion(); + + + // if crypto initialisation was successful, tell it to attach its event + // handlers. + crypto.registerEventHandlers(this); + this._crypto = crypto; +}; + + +/** + * Is end-to-end crypto enabled for this client. + * @return {boolean} True if end-to-end is enabled. + */ +MatrixClient.prototype.isCryptoEnabled = function() { + return this._crypto !== null; +}; + + +/** + * Get the Ed25519 key for this device + * + * @return {?string} base64-encoded ed25519 key. Null if crypto is + * disabled. + */ +MatrixClient.prototype.getDeviceEd25519Key = function() { + if (!this._crypto) { + return null; + } + return this._crypto.getDeviceEd25519Key(); +}; + +/** + * Upload the device keys to the homeserver. + * @return {object} A promise that will resolve when the keys are uploaded. + */ +MatrixClient.prototype.uploadKeys = function() { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + return this._crypto.uploadDeviceKeys(); +}; + +/** + * 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}. + */ +MatrixClient.prototype.downloadKeys = function(userIds, forceDownload) { + if (this._crypto === null) { + return Promise.reject(new Error("End-to-end encryption disabled")); + } + return this._crypto.downloadKeys(userIds, forceDownload); +}; + +/** + * Get the stored device keys for a user id + * + * @param {string} userId the user to list keys for. + * + * @return {Promise} list of devices + */ +MatrixClient.prototype.getStoredDevicesForUser = async function(userId) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + return this._crypto.getStoredDevicesForUser(userId) || []; +}; + +/** + * Get the stored device key for a user id and device id + * + * @param {string} userId the user to list keys for. + * @param {string} deviceId unique identifier for the device + * + * @return {Promise} device or null + */ +MatrixClient.prototype.getStoredDevice = async function(userId, deviceId) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + return this._crypto.getStoredDevice(userId, deviceId) || null; +}; + +/** + * Mark the given device as verified + * + * @param {string} userId owner of the device + * @param {string} deviceId unique identifier for the device + * + * @param {boolean=} verified whether to mark the device as verified. defaults + * to 'true'. + * + * @returns {Promise} + * + * @fires module:client~event:MatrixClient"deviceVerificationChanged" + */ +MatrixClient.prototype.setDeviceVerified = function(userId, deviceId, verified) { + if (verified === undefined) { + verified = true; + } + const prom = _setDeviceVerification(this, userId, deviceId, verified, null); + + // if one of the user's own devices is being marked as verified / unverified, + // check the key backup status, since whether or not we use this depends on + // whether it has a signature from a verified device + if (userId == this.credentials.userId) { + this._crypto.checkKeyBackup(); + } + return prom; +}; + +/** + * Mark the given device as blocked/unblocked + * + * @param {string} userId owner of the device + * @param {string} deviceId unique identifier for the device + * + * @param {boolean=} blocked whether to mark the device as blocked. defaults + * to 'true'. + * + * @returns {Promise} + * + * @fires module:client~event:MatrixClient"deviceVerificationChanged" + */ +MatrixClient.prototype.setDeviceBlocked = function(userId, deviceId, blocked) { + if (blocked === undefined) { + blocked = true; + } + return _setDeviceVerification(this, userId, deviceId, null, blocked); +}; + +/** + * Mark the given device as known/unknown + * + * @param {string} userId owner of the device + * @param {string} deviceId unique identifier for the device + * + * @param {boolean=} known whether to mark the device as known. defaults + * to 'true'. + * + * @returns {Promise} + * + * @fires module:client~event:MatrixClient"deviceVerificationChanged" + */ +MatrixClient.prototype.setDeviceKnown = function(userId, deviceId, known) { + if (known === undefined) { + known = true; + } + return _setDeviceVerification(this, userId, deviceId, null, null, known); +}; + +async function _setDeviceVerification( + client, userId, deviceId, verified, blocked, known, +) { + if (!client._crypto) { + throw new Error("End-to-End encryption disabled"); + } + const dev = await client._crypto.setDeviceVerification( + userId, deviceId, verified, blocked, known, + ); + client.emit("deviceVerificationChanged", userId, deviceId, dev); +} + +/** + * Request a key verification from another user. + * + * @param {string} userId the user to request verification with + * @param {Array} methods array of verification methods to use. Defaults to + * all known methods + * @param {Array} devices array of device IDs to send requests to. Defaults to + * all devices owned by the user + * + * @returns {Promise} resolves to a verifier + * when the request is accepted by the other user + */ +MatrixClient.prototype.requestVerification = function(userId, methods, devices) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + return this._crypto.requestVerification(userId, methods, devices); +}; + +/** + * Begin a key verification. + * + * @param {string} method the verification method to use + * @param {string} userId the user to verify keys with + * @param {string} deviceId the device to verify + * + * @returns {module:crypto/verification/Base} a verification object + */ +MatrixClient.prototype.beginKeyVerification = function( + method, userId, deviceId, +) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + return this._crypto.beginKeyVerification(method, userId, deviceId); +}; + +/** + * 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 + */ +MatrixClient.prototype.setGlobalBlacklistUnverifiedDevices = function(value) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + this._crypto.setGlobalBlacklistUnverifiedDevices(value); +}; + +/** + * @return {boolean} whether to blacklist all unverified devices by default + */ +MatrixClient.prototype.getGlobalBlacklistUnverifiedDevices = function() { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + return this._crypto.getGlobalBlacklistUnverifiedDevices(); +}; + +/** + * Get e2e information on the device that sent an event + * + * @param {MatrixEvent} event event to be checked + * + * @return {Promise} + */ +MatrixClient.prototype.getEventSenderDeviceInfo = async function(event) { + if (!this._crypto) { + return null; + } + + return this._crypto.getEventSenderDeviceInfo(event); +}; + +/** + * Check if the sender of an event is verified + * + * @param {MatrixEvent} event event to be checked + * + * @return {boolean} true if the sender of this event has been verified using + * {@link module:client~MatrixClient#setDeviceVerified|setDeviceVerified}. + */ +MatrixClient.prototype.isEventSenderVerified = async function(event) { + const device = await this.getEventSenderDeviceInfo(event); + if (!device) { + return false; + } + return device.isVerified(); +}; + +/** + * Cancel a room key request for this event if one is ongoing and resend the + * request. + * @param {MatrixEvent} event event of which to cancel and resend the room + * key request. + * @return {Promise} A promise that will resolve when the key request is queued + */ +MatrixClient.prototype.cancelAndResendEventRoomKeyRequest = function(event) { + return event.cancelAndResendKeyRequest(this._crypto, this.getUserId()); +}; + +/** + * Enable end-to-end encryption for a room. + * @param {string} roomId The room ID to enable encryption in. + * @param {object} config The encryption config for the room. + * @return {Promise} A promise that will resolve when encryption is set up. + */ +MatrixClient.prototype.setRoomEncryption = function(roomId, config) { + if (!this._crypto) { + throw new Error("End-to-End encryption disabled"); + } + return this._crypto.setRoomEncryption(roomId, config); +}; + +/** + * Whether encryption is enabled for a room. + * @param {string} roomId the room id to query. + * @return {bool} whether encryption is enabled. + */ +MatrixClient.prototype.isRoomEncrypted = function(roomId) { + const room = this.getRoom(roomId); + if (!room) { + // we don't know about this room, so can't determine if it should be + // encrypted. Let's assume not. + return false; + } + + // if there is an 'm.room.encryption' event in this room, it should be + // encrypted (independently of whether we actually support encryption) + const ev = room.currentState.getStateEvents("m.room.encryption", ""); + if (ev) { + return true; + } + + // we don't have an m.room.encrypted event, but that might be because + // the server is hiding it from us. Check the store to see if it was + // previously encrypted. + return this._roomList.isRoomEncrypted(roomId); +}; + +/** + * 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. + */ +MatrixClient.prototype.forceDiscardSession = function(roomId) { + if (!this._crypto) { + throw new Error("End-to-End encryption disabled"); + } + this._crypto.forceDiscardSession(roomId); +}; + +/** + * Get a list containing all of the room keys + * + * This should be encrypted before returning it to the user. + * + * @return {module:client.Promise} a promise which resolves to a list of + * session export objects + */ +MatrixClient.prototype.exportRoomKeys = function() { + if (!this._crypto) { + return Promise.reject(new Error("End-to-end encryption disabled")); + } + return this._crypto.exportRoomKeys(); +}; + +/** + * Import a list of room keys previously exported by exportRoomKeys + * + * @param {Object[]} keys a list of session export objects + * + * @return {module:client.Promise} a promise which resolves when the keys + * have been imported + */ +MatrixClient.prototype.importRoomKeys = function(keys) { + if (!this._crypto) { + throw new Error("End-to-end encryption disabled"); + } + return this._crypto.importRoomKeys(keys); +}; + +/** + * Force a re-check of the local key backup status against + * what's on the server. + * + * @returns {Object} Object with backup info (as returned by + * getKeyBackupVersion) in backupInfo and + * trust information (as returned by isKeyBackupTrusted) + * in trustInfo. + */ +MatrixClient.prototype.checkKeyBackup = function() { + return this._crypto.checkKeyBackup(); +}; + +/** + * Get information about the current key backup. + * @returns {Promise} Information object from API or null + */ +MatrixClient.prototype.getKeyBackupVersion = function() { + return this._http.authedRequest( + undefined, "GET", "/room_keys/version", undefined, undefined, + {prefix: httpApi.PREFIX_UNSTABLE}, + ).then((res) => { + if (res.algorithm !== olmlib.MEGOLM_BACKUP_ALGORITHM) { + const err = "Unknown backup algorithm: " + res.algorithm; + return Promise.reject(err); + } else if (!(typeof res.auth_data === "object") + || !res.auth_data.public_key) { + const err = "Invalid backup data returned"; + return Promise.reject(err); + } else { + return res; + } + }).catch((e) => { + if (e.errcode === 'M_NOT_FOUND') { + return null; + } else { + throw e; + } + }); +}; + +/** + * @param {object} info key backup info dict from getKeyBackupVersion() + * @return {object} { + * usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device + * sigs: [ + * valid: [bool], + * device: [DeviceInfo], + * ] + * } + */ +MatrixClient.prototype.isKeyBackupTrusted = function(info) { + return this._crypto.isKeyBackupTrusted(info); +}; + +/** + * @returns {bool} true if the client is configured to back up keys to + * the server, otherwise false. + */ +MatrixClient.prototype.getKeyBackupEnabled = function() { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + return Boolean(this._crypto.backupKey); +}; + +/** + * Enable backing up of keys, using data previously returned from + * getKeyBackupVersion. + * + * @param {object} info Backup information object as returned by getKeyBackupVersion + */ +MatrixClient.prototype.enableKeyBackup = function(info) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + this._crypto.backupInfo = info; + if (this._crypto.backupKey) this._crypto.backupKey.free(); + this._crypto.backupKey = new global.Olm.PkEncryption(); + this._crypto.backupKey.set_recipient_key(info.auth_data.public_key); + + this.emit('crypto.keyBackupStatus', true); + + // There may be keys left over from a partially completed backup, so + // schedule a send to check. + this._crypto.scheduleKeyBackupSend(); +}; + +/** + * Disable backing up of keys. + */ +MatrixClient.prototype.disableKeyBackup = function() { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + this._crypto.backupInfo = null; + if (this._crypto.backupKey) this._crypto.backupKey.free(); + this._crypto.backupKey = null; + + this.emit('crypto.keyBackupStatus', false); +}; + +/** + * Set up the data required to create a new backup version. The backup version + * will not be created and enabled until createKeyBackupVersion is called. + * + * @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 that can be passed to createKeyBackupVersion and + * additionally has a 'recovery_key' member with the user-facing recovery key string. + */ +MatrixClient.prototype.prepareKeyBackupVersion = async function(password) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const decryption = new global.Olm.PkDecryption(); + try { + let publicKey; + const authData = {}; + if (password) { + const keyInfo = await keyForNewBackup(password); + publicKey = decryption.init_with_private_key(keyInfo.key); + authData.private_key_salt = keyInfo.salt; + authData.private_key_iterations = keyInfo.iterations; + } else { + publicKey = decryption.generate_key(); + } + + authData.public_key = publicKey; + + return { + algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, + auth_data: authData, + recovery_key: encodeRecoveryKey(decryption.get_private_key()), + }; + } finally { + decryption.free(); + } +}; + +/** + * Create a new key backup version and enable it, using the information return + * from prepareKeyBackupVersion. + * + * @param {object} info Info object from prepareKeyBackupVersion + * @returns {Promise} Object with 'version' param indicating the version created + */ +MatrixClient.prototype.createKeyBackupVersion = function(info) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const data = { + algorithm: info.algorithm, + auth_data: info.auth_data, + }; + return this._crypto._signObject(data.auth_data).then(() => { + return this._http.authedRequest( + undefined, "POST", "/room_keys/version", undefined, data, + {prefix: httpApi.PREFIX_UNSTABLE}, + ); + }).then((res) => { + this.enableKeyBackup({ + algorithm: info.algorithm, + auth_data: info.auth_data, + version: res.version, + }); + return res; + }); +}; + +MatrixClient.prototype.deleteKeyBackupVersion = function(version) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + // If we're currently backing up to this backup... stop. + // (We start using it automatically in createKeyBackupVersion + // so this is symmetrical). + if (this._crypto.backupInfo && this._crypto.backupInfo.version === version) { + this.disableKeyBackup(); + } + + const path = utils.encodeUri("/room_keys/version/$version", { + $version: version, + }); + + return this._http.authedRequest( + undefined, "DELETE", path, undefined, undefined, + {prefix: httpApi.PREFIX_UNSTABLE}, + ); +}; + +MatrixClient.prototype._makeKeyBackupPath = function(roomId, sessionId, version) { + let path; + if (sessionId !== undefined) { + path = utils.encodeUri("/room_keys/keys/$roomId/$sessionId", { + $roomId: roomId, + $sessionId: sessionId, + }); + } else if (roomId !== undefined) { + path = utils.encodeUri("/room_keys/keys/$roomId", { + $roomId: roomId, + }); + } else { + path = "/room_keys/keys"; + } + const queryData = version === undefined ? undefined : { version: version }; + return { + path: path, + queryData: queryData, + }; +}; + +/** + * 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 {object} data Object keys to send + * @return {module:client.Promise} a promise that will resolve when the keys + * are uploaded + */ +MatrixClient.prototype.sendKeyBackup = function(roomId, sessionId, version, data) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const path = this._makeKeyBackupPath(roomId, sessionId, version); + return this._http.authedRequest( + undefined, "PUT", path.path, path.queryData, data, + {prefix: httpApi.PREFIX_UNSTABLE}, + ); +}; + +/** + * Marks all group sessions as needing to be backed up and schedules them to + * upload in the background as soon as possible. + */ +MatrixClient.prototype.scheduleAllGroupSessionsForBackup = async function() { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + await this._crypto.scheduleAllGroupSessionsForBackup(); +}; + +/** + * Marks all group sessions as needing to be backed up without scheduling + * them to upload in the background. + * @returns {Promise} Resolves to the number of sessions requiring a backup. + */ +MatrixClient.prototype.flagAllGroupSessionsForBackup = function() { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + return this._crypto.flagAllGroupSessionsForBackup(); +}; + +MatrixClient.prototype.isValidRecoveryKey = function(recoveryKey) { + try { + decodeRecoveryKey(recoveryKey); + return true; + } catch (e) { + return false; + } +}; + +MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY = 'RESTORE_BACKUP_ERROR_BAD_KEY'; + +MatrixClient.prototype.restoreKeyBackupWithPassword = async function( + password, targetRoomId, targetSessionId, backupInfo, +) { + const privKey = await keyForExistingBackup(backupInfo, password); + return this._restoreKeyBackup( + privKey, targetRoomId, targetSessionId, backupInfo, + ); +}; + +MatrixClient.prototype.restoreKeyBackupWithRecoveryKey = function( + recoveryKey, targetRoomId, targetSessionId, backupInfo, +) { + const privKey = decodeRecoveryKey(recoveryKey); + return this._restoreKeyBackup( + privKey, targetRoomId, targetSessionId, backupInfo, + ); +}; + +MatrixClient.prototype._restoreKeyBackup = function( + privKey, targetRoomId, targetSessionId, backupInfo, +) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + let totalKeyCount = 0; + let keys = []; + + const path = this._makeKeyBackupPath( + targetRoomId, targetSessionId, backupInfo.version, + ); + + const decryption = new global.Olm.PkDecryption(); + let backupPubKey; + try { + backupPubKey = decryption.init_with_private_key(privKey); + } catch(e) { + decryption.free(); + throw e; + } + + // If the pubkey computed from the private data we've been given + // doesn't match the one in the auth_data, the user has enetered + // a different recovery key / the wrong passphrase. + if (backupPubKey !== backupInfo.auth_data.public_key) { + return Promise.reject({errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY}); + } + + return this._http.authedRequest( + undefined, "GET", path.path, path.queryData, undefined, + {prefix: httpApi.PREFIX_UNSTABLE}, + ).then((res) => { + if (res.rooms) { + for (const [roomId, roomData] of Object.entries(res.rooms)) { + if (!roomData.sessions) continue; + + totalKeyCount += Object.keys(roomData.sessions).length; + const roomKeys = keysFromRecoverySession( + roomData.sessions, decryption, roomId, roomKeys, + ); + for (const k of roomKeys) { + k.room_id = roomId; + keys.push(k); + } + } + } else if (res.sessions) { + totalKeyCount = Object.keys(res.sessions).length; + keys = keysFromRecoverySession( + res.sessions, decryption, targetRoomId, keys, + ); + } else { + totalKeyCount = 1; + try { + const key = keyFromRecoverySession(res, decryption); + key.room_id = targetRoomId; + key.session_id = targetSessionId; + keys.push(key); + } catch (e) { + logger.log("Failed to decrypt session from backup"); + } + } + + return this.importRoomKeys(keys); + }).then(() => { + return this._crypto.setTrustedBackupPubKey(backupPubKey); + }).then(() => { + return {total: totalKeyCount, imported: keys.length}; + }).finally(() => { + decryption.free(); + }); +}; + +MatrixClient.prototype.deleteKeysFromBackup = function(roomId, sessionId, version) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const path = this._makeKeyBackupPath(roomId, sessionId, version); + return this._http.authedRequest( + undefined, "DELETE", path.path, path.queryData, undefined, + {prefix: httpApi.PREFIX_UNSTABLE}, + ); +}; + +// Group ops +// ========= +// Operations on groups that come down the sync stream (ie. ones the +// user is a member of or invited to) + +/** + * Get the group for the given group ID. + * This function will return a valid group for any group for which a Group event + * has been emitted. + * @param {string} groupId The group ID + * @return {Group} The Group or null if the group is not known or there is no data store. + */ +MatrixClient.prototype.getGroup = function(groupId) { + return this.store.getGroup(groupId); +}; + +/** + * Retrieve all known groups. + * @return {Group[]} A list of groups, or an empty list if there is no data store. + */ +MatrixClient.prototype.getGroups = function() { + return this.store.getGroups(); +}; + +/** + * Get the config for the media repository. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves with an object containing the config. + */ +MatrixClient.prototype.getMediaConfig = function(callback) { + return this._http.authedRequestWithPrefix( + callback, "GET", "/config", undefined, undefined, httpApi.PREFIX_MEDIA_R0, + ); +}; + +// Room ops +// ======== + +/** + * Get the room for the given room ID. + * This function will return a valid room for any room for which a Room event + * has been emitted. Note in particular that other events, eg. RoomState.members + * will be emitted for a room before this function will return the given room. + * @param {string} roomId The room ID + * @return {Room} The Room or null if it doesn't exist or there is no data store. + */ +MatrixClient.prototype.getRoom = function(roomId) { + return this.store.getRoom(roomId); +}; + +/** + * Retrieve all known rooms. + * @return {Room[]} A list of rooms, or an empty list if there is no data store. + */ +MatrixClient.prototype.getRooms = function() { + return this.store.getRooms(); +}; + +/** + * Retrieve all rooms that should be displayed to the user + * This is essentially getRooms() with some rooms filtered out, eg. old versions + * of rooms that have been replaced or (in future) other rooms that have been + * marked at the protocol level as not to be displayed to the user. + * @return {Room[]} A list of rooms, or an empty list if there is no data store. + */ +MatrixClient.prototype.getVisibleRooms = function() { + const allRooms = this.store.getRooms(); + + const replacedRooms = new Set(); + for (const r of allRooms) { + const createEvent = r.currentState.getStateEvents('m.room.create', ''); + // invites are included in this list and we don't know their create events yet + if (createEvent) { + const predecessor = createEvent.getContent()['predecessor']; + if (predecessor && predecessor['room_id']) { + replacedRooms.add(predecessor['room_id']); + } + } + } + + return allRooms.filter((r) => { + const tombstone = r.currentState.getStateEvents('m.room.tombstone', ''); + if (tombstone && replacedRooms.has(r.roomId)) { + return false; + } + return true; + }); +}; + +/** + * Retrieve a user. + * @param {string} userId The user ID to retrieve. + * @return {?User} A user or null if there is no data store or the user does + * not exist. + */ +MatrixClient.prototype.getUser = function(userId) { + return this.store.getUser(userId); +}; + +/** + * Retrieve all known users. + * @return {User[]} A list of users, or an empty list if there is no data store. + */ +MatrixClient.prototype.getUsers = function() { + return this.store.getUsers(); +}; + +// User Account Data operations +// ============================ + +/** + * Set account data event for the current user. + * @param {string} eventType The event type + * @param {Object} contents the contents object for the event + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setAccountData = function(eventType, contents, callback) { + const path = utils.encodeUri("/user/$userId/account_data/$type", { + $userId: this.credentials.userId, + $type: eventType, + }); + return this._http.authedRequest( + callback, "PUT", path, undefined, contents, + ); +}; + +/** + * Get account data event of given type for the current user. + * @param {string} eventType The event type + * @return {?object} The contents of the given account data event + */ +MatrixClient.prototype.getAccountData = function(eventType) { + return this.store.getAccountData(eventType); +}; + +/** + * Gets the users that are ignored by this client + * @returns {string[]} The array of users that are ignored (empty if none) + */ +MatrixClient.prototype.getIgnoredUsers = function() { + const event = this.getAccountData("m.ignored_user_list"); + if (!event || !event.getContent() || !event.getContent()["ignored_users"]) return []; + return Object.keys(event.getContent()["ignored_users"]); +}; + +/** + * Sets the users that the current user should ignore. + * @param {string[]} userIds the user IDs to ignore + * @param {module:client.callback} [callback] Optional. + * @return {module:client.Promise} Resolves: Account data event + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setIgnoredUsers = function(userIds, callback) { + const content = {ignored_users: {}}; + userIds.map((u) => content.ignored_users[u] = {}); + return this.setAccountData("m.ignored_user_list", content, callback); +}; + +/** + * Gets whether or not a specific user is being ignored by this client. + * @param {string} userId the user ID to check + * @returns {boolean} true if the user is ignored, false otherwise + */ +MatrixClient.prototype.isUserIgnored = function(userId) { + return this.getIgnoredUsers().indexOf(userId) !== -1; +}; + +// Room operations +// =============== + +/** + * Join a room. If you have already joined the room, this will no-op. + * @param {string} roomIdOrAlias The room ID or room alias to join. + * @param {Object} opts Options when joining the room. + * @param {boolean} opts.syncRoom True to do a room initial sync on the resulting + * room. If false, the returned Room object will have no current state. + * Default: true. + * @param {boolean} opts.inviteSignUrl If the caller has a keypair 3pid invite, + * the signing URL is passed in this parameter. + * @param {string[]} opts.viaServers The server names to try and join through in + * addition to those that are automatically chosen. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: Room object. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.joinRoom = function(roomIdOrAlias, opts, callback) { + // to help people when upgrading.. + if (utils.isFunction(opts)) { + throw new Error("Expected 'opts' object, got function."); + } + opts = opts || {}; + if (opts.syncRoom === undefined) { + opts.syncRoom = true; + } + + const room = this.getRoom(roomIdOrAlias); + if (room && room.hasMembershipState(this.credentials.userId, "join")) { + return Promise.resolve(room); + } + + let sign_promise = Promise.resolve(); + + if (opts.inviteSignUrl) { + sign_promise = this._http.requestOtherUrl( + undefined, 'POST', + opts.inviteSignUrl, { mxid: this.credentials.userId }, + ); + } + + const queryString = {}; + if (opts.viaServers) { + queryString["server_name"] = opts.viaServers; + } + + const reqOpts = {qsStringifyOptions: {arrayFormat: 'repeat'}}; + + const defer = Promise.defer(); + + const self = this; + sign_promise.then(function(signed_invite_object) { + const data = {}; + if (signed_invite_object) { + data.third_party_signed = signed_invite_object; + } + + const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias}); + return self._http.authedRequest( + undefined, "POST", path, queryString, data, reqOpts); + }).then(function(res) { + const roomId = res.room_id; + const syncApi = new SyncApi(self, self._clientOpts); + const room = syncApi.createRoom(roomId); + if (opts.syncRoom) { + // v2 will do this for us + // return syncApi.syncRoom(room); + } + return Promise.resolve(room); + }).done(function(room) { + _resolve(callback, defer, room); + }, function(err) { + _reject(callback, defer, err); + }); + return defer.promise; +}; + +/** + * Resend an event. + * @param {MatrixEvent} event The event to resend. + * @param {Room} room Optional. The room the event is in. Will update the + * timeline entry if provided. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.resendEvent = function(event, room) { + _updatePendingEventStatus(room, event, EventStatus.SENDING); + return _sendEvent(this, room, event); +}; + +/** + * Cancel a queued or unsent event. + * + * @param {MatrixEvent} event Event to cancel + * @throws Error if the event is not in QUEUED or NOT_SENT state + */ +MatrixClient.prototype.cancelPendingEvent = function(event) { + if ([EventStatus.QUEUED, EventStatus.NOT_SENT].indexOf(event.status) < 0) { + throw new Error("cannot cancel an event with status " + event.status); + } + + // first tell the scheduler to forget about it, if it's queued + if (this.scheduler) { + this.scheduler.removeEventFromQueue(event); + } + + // then tell the room about the change of state, which will remove it + // from the room's list of pending events. + const room = this.getRoom(event.getRoomId()); + _updatePendingEventStatus(room, event, EventStatus.CANCELLED); +}; + +/** + * @param {string} roomId + * @param {string} name + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setRoomName = function(roomId, name, callback) { + return this.sendStateEvent(roomId, "m.room.name", {name: name}, + undefined, callback); +}; + +/** + * @param {string} roomId + * @param {string} topic + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setRoomTopic = function(roomId, topic, callback) { + return this.sendStateEvent(roomId, "m.room.topic", {topic: topic}, + undefined, callback); +}; + +/** + * @param {string} roomId + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.getRoomTags = function(roomId, callback) { + const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/", { + $userId: this.credentials.userId, + $roomId: roomId, + }); + return this._http.authedRequest( + callback, "GET", path, undefined, + ); +}; + +/** + * @param {string} roomId + * @param {string} tagName name of room tag to be set + * @param {object} metadata associated with that tag to be stored + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setRoomTag = function(roomId, tagName, metadata, callback) { + const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { + $userId: this.credentials.userId, + $roomId: roomId, + $tag: tagName, + }); + return this._http.authedRequest( + callback, "PUT", path, undefined, metadata, + ); +}; + +/** + * @param {string} roomId + * @param {string} tagName name of room tag to be removed + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.deleteRoomTag = function(roomId, tagName, callback) { + const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", { + $userId: this.credentials.userId, + $roomId: roomId, + $tag: tagName, + }); + return this._http.authedRequest( + callback, "DELETE", path, undefined, undefined, + ); +}; + +/** + * @param {string} roomId + * @param {string} eventType event type to be set + * @param {object} content event content + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setRoomAccountData = function(roomId, eventType, + content, callback) { + const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", { + $userId: this.credentials.userId, + $roomId: roomId, + $type: eventType, + }); + return this._http.authedRequest( + callback, "PUT", path, undefined, content, + ); +}; + +/** + * Set a user's power level. + * @param {string} roomId + * @param {string} userId + * @param {Number} powerLevel + * @param {MatrixEvent} event + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setPowerLevel = function(roomId, userId, powerLevel, + event, callback) { + let content = { + users: {}, + }; + 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.users[userId] = powerLevel; + const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", { + $roomId: roomId, + }); + return this._http.authedRequest( + callback, "PUT", path, undefined, content, + ); +}; + +/** + * @param {string} roomId + * @param {string} eventType + * @param {Object} content + * @param {string} txnId Optional. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId, + callback) { + return this._sendCompleteEvent(roomId, { + type: eventType, + content: content, + }, txnId, callback); +}; +/** + * @param {string} roomId + * @param {object} eventObject An object with the partial structure of an event, to which event_id, user_id, room_id and origin_server_ts will be added. + * @param {string} txnId the txnId. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype._sendCompleteEvent = function(roomId, eventObject, txnId, + callback) { + if (utils.isFunction(txnId)) { + callback = txnId; txnId = undefined; + } + + if (!txnId) { + txnId = this.makeTxnId(); + } + + // we always construct a MatrixEvent when sending because the store and + // scheduler use them. We'll extract the params back out if it turns out + // the client has no scheduler or store. + const localEvent = new MatrixEvent(Object.assign(eventObject, { + event_id: "~" + roomId + ":" + txnId, + user_id: this.credentials.userId, + room_id: roomId, + origin_server_ts: new Date().getTime(), + })); + + const room = this.getRoom(roomId); + + // if this is a relation or redaction of an event + // that hasn't been sent yet (e.g. with a local id starting with a ~) + // then listen for the remote echo of that event so that by the time + // this event does get sent, we have the correct event_id + const targetId = localEvent.getAssociatedId(); + if (targetId && targetId.startsWith("~")) { + const target = room.getPendingEvents().find(e => e.getId() === targetId); + target.once("Event.localEventIdReplaced", () => { + localEvent.updateAssociatedId(target.getId()); + }); + } + + const type = localEvent.getType(); + logger.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`); + + localEvent._txnId = txnId; + localEvent.setStatus(EventStatus.SENDING); + + // add this event immediately to the local store as 'sending'. + if (room) { + room.addPendingEvent(localEvent, txnId); + } + + // addPendingEvent can change the state to NOT_SENT if it believes + // that there's other events that have failed. We won't bother to + // try sending the event if the state has changed as such. + if (localEvent.status === EventStatus.NOT_SENT) { + return Promise.reject(new Error("Event blocked by other events not yet sent")); + } + + return _sendEvent(this, room, localEvent, callback); +}; + + +// encrypts the event if necessary +// adds the event to the queue, or sends it +// marks the event as sent/unsent +// returns a promise which resolves with the result of the send request +function _sendEvent(client, room, event, callback) { + // Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections, + // so that we can handle synchronous and asynchronous exceptions with the + // same code path. + return Promise.resolve().then(function() { + const encryptionPromise = _encryptEventIfNeeded(client, event, room); + + if (!encryptionPromise) { + return null; + } + + _updatePendingEventStatus(room, event, EventStatus.ENCRYPTING); + return encryptionPromise.then(() => { + _updatePendingEventStatus(room, event, EventStatus.SENDING); + }); + }).then(function() { + let promise; + // this event may be queued + if (client.scheduler) { + // if this returns a promsie then the scheduler has control now and will + // resolve/reject when it is done. Internally, the scheduler will invoke + // processFn which is set to this._sendEventHttpRequest so the same code + // path is executed regardless. + promise = client.scheduler.queueEvent(event); + if (promise && client.scheduler.getQueueForEvent(event).length > 1) { + // event is processed FIFO so if the length is 2 or more we know + // this event is stuck behind an earlier event. + _updatePendingEventStatus(room, event, EventStatus.QUEUED); + } + } + + if (!promise) { + promise = _sendEventHttpRequest(client, event); + } + return promise; + }).then(function(res) { // the request was sent OK + if (room) { + room.updatePendingEvent(event, EventStatus.SENT, res.event_id); + } + if (callback) { + callback(null, res); + } + return res; + }, function(err) { + // the request failed to send. + logger.error("Error sending event", err.stack || err); + + try { + // set the error on the event before we update the status: + // updating the status emits the event, so the state should be + // consistent at that point. + event.error = err; + _updatePendingEventStatus(room, event, EventStatus.NOT_SENT); + // also put the event object on the error: the caller will need this + // to resend or cancel the event + err.event = event; + + if (callback) { + callback(err); + } + } catch (err2) { + logger.error("Exception in error handler!", err2.stack || err); + } + throw err; + }); +} + +/** + * Encrypt an event according to the configuration of the room, if necessary. + * + * @param {MatrixClient} client + * + * @param {module:models/event.MatrixEvent} event event to be sent + * + * @param {module:models/room?} room destination room. Null if the destination + * is not a room we have seen over the sync pipe. + * + * @return {module:client.Promise?} Promise which resolves when the event has been + * encrypted, or null if nothing was needed + */ + +function _encryptEventIfNeeded(client, event, room) { + if (event.isEncrypted()) { + // this event has already been encrypted; this happens if the + // encryption step succeeded, but the send step failed on the first + // attempt. + return null; + } + + if (!client.isRoomEncrypted(event.getRoomId())) { + // looks like this room isn't encrypted. + return null; + } + + if (event.getType() === "m.reaction") { + // For reactions, there is a very little gained by encrypting the entire + // event, as relation data is already kept in the clear. Event + // encryption for a reaction effectively only obscures the event type, + // but the purpose is still obvious from the relation data, so nothing + // is really gained. It also causes quite a few problems, such as: + // * triggers notifications via default push rules + // * prevents server-side bundling for reactions + // The reaction key / content / emoji value does warrant encrypting, but + // this will be handled separately by encrypting just this value. + // See https://github.com/matrix-org/matrix-doc/pull/1849#pullrequestreview-248763642 + return null; + } + + if (!client._crypto) { + throw new Error( + "This room is configured to use encryption, but your client does " + + "not support encryption.", + ); + } + + return client._crypto.encryptEvent(event, room); +} +/** + * Returns the eventType that should be used taking encryption into account + * for a given eventType. + * @param {MatrixClient} client the client + * @param {string} roomId the room for the events `eventType` relates to + * @param {string} eventType the event type + * @return {string} the event type taking encryption into account + */ +function _getEncryptedIfNeededEventType(client, roomId, eventType) { + if (eventType === "m.reaction") { + return eventType; + } + const isEncrypted = client.isRoomEncrypted(roomId); + return isEncrypted ? "m.room.encrypted" : eventType; +} + +function _updatePendingEventStatus(room, event, newStatus) { + if (room) { + room.updatePendingEvent(event, newStatus); + } else { + event.setStatus(newStatus); + } +} + +function _sendEventHttpRequest(client, event) { + const txnId = event._txnId ? event._txnId : client.makeTxnId(); + + const pathParams = { + $roomId: event.getRoomId(), + $eventType: event.getWireType(), + $stateKey: event.getStateKey(), + $txnId: txnId, + }; + + let path; + + if (event.isState()) { + let pathTemplate = "/rooms/$roomId/state/$eventType"; + if (event.getStateKey() && event.getStateKey().length > 0) { + pathTemplate = "/rooms/$roomId/state/$eventType/$stateKey"; + } + path = utils.encodeUri(pathTemplate, pathParams); + } else if (event.isRedaction()) { + const pathTemplate = `/rooms/$roomId/redact/$redactsEventId/$txnId`; + path = utils.encodeUri(pathTemplate, Object.assign({ + $redactsEventId: event.event.redacts, + }, pathParams)); + } else { + path = utils.encodeUri( + "/rooms/$roomId/send/$eventType/$txnId", pathParams, + ); + } + + return client._http.authedRequest( + undefined, "PUT", path, undefined, event.getWireContent(), + ).then((res) => { + logger.log( + `Event sent to ${event.getRoomId()} with event id ${res.event_id}`, + ); + return res; + }); +} + +/** + * @param {string} roomId + * @param {string} eventId + * @param {string} [txnId] transaction id. One will be made up if not + * supplied. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.redactEvent = function(roomId, eventId, txnId, callback) { + return this._sendCompleteEvent(roomId, { + type: "m.room.redaction", + content: {}, + redacts: eventId, + }, txnId, callback); +}; + +/** + * @param {string} roomId + * @param {Object} content + * @param {string} txnId Optional. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendMessage = function(roomId, content, txnId, callback) { + if (utils.isFunction(txnId)) { + callback = txnId; txnId = undefined; + } + return this.sendEvent( + roomId, "m.room.message", content, txnId, callback, + ); +}; + +/** + * @param {string} roomId + * @param {string} body + * @param {string} txnId Optional. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendTextMessage = function(roomId, body, txnId, callback) { + const content = ContentHelpers.makeTextMessage(body); + return this.sendMessage(roomId, content, txnId, callback); +}; + +/** + * @param {string} roomId + * @param {string} body + * @param {string} txnId Optional. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendNotice = function(roomId, body, txnId, callback) { + const content = ContentHelpers.makeNotice(body); + return this.sendMessage(roomId, content, txnId, callback); +}; + +/** + * @param {string} roomId + * @param {string} body + * @param {string} txnId Optional. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendEmoteMessage = function(roomId, body, txnId, callback) { + const content = ContentHelpers.makeEmoteMessage(body); + return this.sendMessage(roomId, content, txnId, callback); +}; + +/** + * @param {string} roomId + * @param {string} url + * @param {Object} info + * @param {string} text + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendImageMessage = function(roomId, url, info, text, callback) { + if (utils.isFunction(text)) { + callback = text; text = undefined; + } + if (!text) { + text = "Image"; + } + const content = { + msgtype: "m.image", + url: url, + info: info, + body: text, + }; + return this.sendMessage(roomId, content, callback); +}; + +/** + * @param {string} roomId + * @param {string} url + * @param {Object} info + * @param {string} text + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendStickerMessage = function(roomId, url, info, text, callback) { + if (utils.isFunction(text)) { + callback = text; text = undefined; + } + if (!text) { + text = "Sticker"; + } + const content = { + url: url, + info: info, + body: text, + }; + return this.sendEvent( + roomId, "m.sticker", content, callback, undefined, + ); +}; + +/** + * @param {string} roomId + * @param {string} body + * @param {string} htmlBody + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendHtmlMessage = function(roomId, body, htmlBody, callback) { + const content = ContentHelpers.makeHtmlMessage(body, htmlBody); + return this.sendMessage(roomId, content, callback); +}; + +/** + * @param {string} roomId + * @param {string} body + * @param {string} htmlBody + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendHtmlNotice = function(roomId, body, htmlBody, callback) { + const content = ContentHelpers.makeHtmlNotice(body, htmlBody); + return this.sendMessage(roomId, content, callback); +}; + +/** + * @param {string} roomId + * @param {string} body + * @param {string} htmlBody + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendHtmlEmote = function(roomId, body, htmlBody, callback) { + const content = ContentHelpers.makeHtmlEmote(body, htmlBody); + return this.sendMessage(roomId, content, callback); +}; + +/** + * Send a receipt. + * @param {Event} event The event being acknowledged + * @param {string} receiptType The kind of receipt e.g. "m.read" + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendReceipt = function(event, receiptType, callback) { + if (this.isGuest()) { + return Promise.resolve({}); // guests cannot send receipts so don't bother. + } + + const path = utils.encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { + $roomId: event.getRoomId(), + $receiptType: receiptType, + $eventId: event.getId(), + }); + const promise = this._http.authedRequest( + callback, "POST", path, undefined, {}, + ); + + const room = this.getRoom(event.getRoomId()); + if (room) { + room._addLocalEchoReceipt(this.credentials.userId, event, receiptType); + } + return promise; +}; + +/** + * Send a read receipt. + * @param {Event} event The event that has been read. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendReadReceipt = async function(event, callback) { + const eventId = event.getId(); + const room = this.getRoom(event.getRoomId()); + if (room && room.hasPendingEvent(eventId)) { + throw new Error(`Cannot set read receipt to a pending event (${eventId})`); + } + return this.sendReceipt(event, "m.read", callback); +}; + +/** + * Set a marker to indicate the point in a room before which the user has read every + * event. This can be retrieved from room account data (the event type is `m.fully_read`) + * and displayed as a horizontal line in the timeline that is visually distinct to the + * position of the user's own read receipt. + * @param {string} roomId ID of the room that has been read + * @param {string} rmEventId ID of the event that has been read + * @param {string} rrEvent the event tracked by the read receipt. This is here for + * convenience because the RR and the RM are commonly updated at the same time as each + * other. The local echo of this receipt will be done if set. Optional. + * @return {module:client.Promise} Resolves: the empty object, {}. + */ +MatrixClient.prototype.setRoomReadMarkers = async function(roomId, rmEventId, rrEvent) { + const room = this.getRoom(roomId); + if (room && room.hasPendingEvent(rmEventId)) { + throw new Error(`Cannot set read marker to a pending event (${rmEventId})`); + } + + // Add the optional RR update, do local echo like `sendReceipt` + let rrEventId; + if (rrEvent) { + rrEventId = rrEvent.getId(); + if (room && room.hasPendingEvent(rrEventId)) { + throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`); + } + if (room) { + room._addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read"); + } + } + + return this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId); +}; + +/** + * Get a preview of the given URL as of (roughly) the given point in time, + * described as an object with OpenGraph keys and associated values. + * Attributes may be synthesized where actual OG metadata is lacking. + * Caches results to prevent hammering the server. + * @param {string} url The URL to get preview data for + * @param {Number} ts The preferred point in time that the preview should + * describe (ms since epoch). The preview returned will either be the most + * recent one preceding this timestamp if available, or failing that the next + * most recent available preview. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: Object of OG metadata. + * @return {module:http-api.MatrixError} Rejects: with an error response. + * May return synthesized attributes if the URL lacked OG meta. + */ +MatrixClient.prototype.getUrlPreview = function(url, ts, callback) { + const key = ts + "_" + url; + const og = this.urlPreviewCache[key]; + if (og) { + return Promise.resolve(og); + } + + const self = this; + return this._http.authedRequestWithPrefix( + callback, "GET", "/preview_url", { + url: url, + ts: ts, + }, undefined, httpApi.PREFIX_MEDIA_R0, + ).then(function(response) { + // TODO: expire cache occasionally + self.urlPreviewCache[key] = response; + return response; + }); +}; + +/** + * @param {string} roomId + * @param {boolean} isTyping + * @param {Number} timeoutMs + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.sendTyping = function(roomId, isTyping, timeoutMs, callback) { + if (this.isGuest()) { + return Promise.resolve({}); // guests cannot send typing notifications so don't bother. + } + + const path = utils.encodeUri("/rooms/$roomId/typing/$userId", { + $roomId: roomId, + $userId: this.credentials.userId, + }); + const data = { + typing: isTyping, + }; + if (isTyping) { + data.timeout = timeoutMs ? timeoutMs : 20000; + } + return this._http.authedRequest( + callback, "PUT", path, undefined, data, + ); +}; + +/** + * Determines the history of room upgrades for a given room, as far as the + * client can see. Returns an array of Rooms where the first entry is the + * oldest and the last entry is the newest (likely current) room. If the + * provided room is not found, this returns an empty list. This works in + * both directions, looking for older and newer rooms of the given room. + * @param {string} roomId The room ID to search from + * @param {boolean} verifyLinks If true, the function will only return rooms + * which can be proven to be linked. For example, rooms which have a create + * event pointing to an old room which the client is not aware of or doesn't + * have a matching tombstone would not be returned. + * @return {Room[]} An array of rooms representing the upgrade + * history. + */ +MatrixClient.prototype.getRoomUpgradeHistory = function(roomId, verifyLinks=false) { + let currentRoom = this.getRoom(roomId); + if (!currentRoom) return []; + + const upgradeHistory = [currentRoom]; + + // Work backwards first, looking at create events. + let createEvent = currentRoom.currentState.getStateEvents("m.room.create", ""); + while (createEvent) { + logger.log(`Looking at ${createEvent.getId()}`); + const predecessor = createEvent.getContent()['predecessor']; + if (predecessor && predecessor['room_id']) { + logger.log(`Looking at predecessor ${predecessor['room_id']}`); + const refRoom = this.getRoom(predecessor['room_id']); + if (!refRoom) break; // end of the chain + + if (verifyLinks) { + const tombstone = refRoom.currentState + .getStateEvents("m.room.tombstone", ""); + + if (!tombstone + || tombstone.getContent()['replacement_room'] !== refRoom.roomId) { + break; + } + } + + // Insert at the front because we're working backwards from the currentRoom + upgradeHistory.splice(0, 0, refRoom); + createEvent = refRoom.currentState.getStateEvents("m.room.create", ""); + } else { + // No further create events to look at + break; + } + } + + // Work forwards next, looking at tombstone events + let tombstoneEvent = currentRoom.currentState.getStateEvents("m.room.tombstone", ""); + while (tombstoneEvent) { + const refRoom = this.getRoom(tombstoneEvent.getContent()['replacement_room']); + if (!refRoom) break; // end of the chain + + if (verifyLinks) { + createEvent = refRoom.currentState.getStateEvents("m.room.create", ""); + if (!createEvent || !createEvent.getContent()['predecessor']) break; + + const predecessor = createEvent.getContent()['predecessor']; + if (predecessor['room_id'] !== currentRoom.roomId) break; + } + + // Push to the end because we're looking forwards + upgradeHistory.push(refRoom); + + // Set the current room to the reference room so we know where we're at + currentRoom = refRoom; + tombstoneEvent = currentRoom.currentState.getStateEvents("m.room.tombstone", ""); + } + + return upgradeHistory; +}; + +/** + * @param {string} roomId + * @param {string} userId + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.invite = function(roomId, userId, callback) { + return _membershipChange(this, roomId, userId, "invite", undefined, + callback); +}; + +/** + * Invite a user to a room based on their email address. + * @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 {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.inviteByEmail = function(roomId, email, callback) { + return this.inviteByThreePid( + roomId, "email", email, callback, + ); +}; + +/** + * Invite a user to a room based on a third-party identifier. + * @param {string} roomId The room to invite the user to. + * @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 {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.inviteByThreePid = function(roomId, medium, address, callback) { + const path = utils.encodeUri( + "/rooms/$roomId/invite", + { $roomId: roomId }, + ); + + const identityServerUrl = this.getIdentityServerUrl(true); + if (!identityServerUrl) { + return Promise.reject(new MatrixError({ + error: "No supplied identity server URL", + errcode: "ORG.MATRIX.JSSDK_MISSING_PARAM", + })); + } + + return this._http.authedRequest(callback, "POST", path, undefined, { + id_server: identityServerUrl, + medium: medium, + address: address, + }); +}; + +/** + * @param {string} roomId + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.leave = function(roomId, callback) { + return _membershipChange(this, roomId, undefined, "leave", undefined, + callback); +}; + +/** + * Leaves all rooms in the chain of room upgrades based on the given room. By + * default, this will leave all the previous and upgraded rooms, including the + * given room. To only leave the given room and any previous rooms, keeping the + * upgraded (modern) rooms untouched supply `false` to `includeFuture`. + * @param {string} roomId The room ID to start leaving at + * @param {boolean} includeFuture If true, the whole chain (past and future) of + * upgraded rooms will be left. + * @return {module:client.Promise} Resolves when completed with an object keyed + * by room ID and value of the error encountered when leaving or null. + */ +MatrixClient.prototype.leaveRoomChain = function(roomId, includeFuture=true) { + const upgradeHistory = this.getRoomUpgradeHistory(roomId); + + let eligibleToLeave = upgradeHistory; + if (!includeFuture) { + eligibleToLeave = []; + for (const room of upgradeHistory) { + eligibleToLeave.push(room); + if (room.roomId === roomId) { + break; + } + } + } + + const populationResults = {}; // {roomId: Error} + const promises = []; + + const doLeave = (roomId) => { + return this.leave(roomId).then(() => { + populationResults[roomId] = null; + }).catch((err) => { + populationResults[roomId] = err; + return null; // suppress error + }); + }; + + for (const room of eligibleToLeave) { + promises.push(doLeave(room.roomId)); + } + + return Promise.all(promises).then(() => populationResults); +}; + +/** + * @param {string} roomId + * @param {string} userId + * @param {string} reason Optional. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.ban = function(roomId, userId, reason, callback) { + return _membershipChange(this, roomId, userId, "ban", reason, + callback); +}; + +/** + * @param {string} roomId + * @param {boolean} deleteRoom True to delete the room from the store on success. + * Default: true. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.forget = function(roomId, deleteRoom, callback) { + if (deleteRoom === undefined) { + deleteRoom = true; + } + const promise = _membershipChange(this, roomId, undefined, "forget", undefined, + callback); + if (!deleteRoom) { + return promise; + } + const self = this; + return promise.then(function(response) { + self.store.removeRoom(roomId); + self.emit("deleteRoom", roomId); + return response; + }); +}; + +/** + * @param {string} roomId + * @param {string} userId + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: Object (currently empty) + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.unban = function(roomId, userId, callback) { + // unbanning != set their state to leave: this used to be + // the case, but was then changed so that leaving was always + // a revoking of priviledge, otherwise two people racing to + // kick / ban someone could end up banning and then un-banning + // them. + const path = utils.encodeUri("/rooms/$roomId/unban", { + $roomId: roomId, + }); + const data = { + user_id: userId, + }; + return this._http.authedRequest( + callback, "POST", path, undefined, data, + ); +}; + +/** + * @param {string} roomId + * @param {string} userId + * @param {string} reason Optional. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.kick = function(roomId, userId, reason, callback) { + return _setMembershipState( + this, roomId, userId, "leave", reason, callback, + ); +}; + +/** + * This is an internal method. + * @param {MatrixClient} client + * @param {string} roomId + * @param {string} userId + * @param {string} membershipValue + * @param {string} reason + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +function _setMembershipState(client, roomId, userId, membershipValue, reason, + callback) { + if (utils.isFunction(reason)) { + callback = reason; reason = undefined; + } + + const path = utils.encodeUri( + "/rooms/$roomId/state/m.room.member/$userId", + { $roomId: roomId, $userId: userId}, + ); + + return client._http.authedRequest(callback, "PUT", path, undefined, { + membership: membershipValue, + reason: reason, + }); +} + +/** + * This is an internal method. + * @param {MatrixClient} client + * @param {string} roomId + * @param {string} userId + * @param {string} membership + * @param {string} reason + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +function _membershipChange(client, roomId, userId, membership, reason, callback) { + if (utils.isFunction(reason)) { + callback = reason; reason = undefined; + } + + const path = utils.encodeUri("/rooms/$room_id/$membership", { + $room_id: roomId, + $membership: membership, + }); + return client._http.authedRequest( + callback, "POST", path, undefined, { + user_id: userId, // may be undefined e.g. on leave + reason: reason, + }, + ); +} + +/** + * Obtain a dict of actions which should be performed for this event according + * to the push rules for this user. Caches the dict on the event. + * @param {MatrixEvent} event The event to get push actions for. + * @return {module:pushprocessor~PushAction} A dict of actions to perform. + */ +MatrixClient.prototype.getPushActionsForEvent = function(event) { + if (!event.getPushActions()) { + event.setPushActions(this._pushProcessor.actionsForEvent(event)); + } + return event.getPushActions(); +}; + +// Profile operations +// ================== + +/** + * @param {string} info The kind of info to set (e.g. 'avatar_url') + * @param {Object} data The JSON object to set. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setProfileInfo = function(info, data, callback) { + const path = utils.encodeUri("/profile/$userId/$info", { + $userId: this.credentials.userId, + $info: info, + }); + return this._http.authedRequest( + callback, "PUT", path, undefined, data, + ); +}; + +/** + * @param {string} name + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setDisplayName = function(name, callback) { + return this.setProfileInfo( + "displayname", { displayname: name }, callback, + ); +}; + +/** + * @param {string} url + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setAvatarUrl = function(url, callback) { + return this.setProfileInfo( + "avatar_url", { avatar_url: url }, callback, + ); +}; + +/** + * Turn an MXC URL into an HTTP one. This method is experimental and + * may change. + * @param {string} mxcUrl The MXC URL + * @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} allowDirectLinks If true, return any non-mxc URLs + * directly. Fetching such URLs will leak information about the user to + * anyone they share a room with. If false, will return null for such URLs. + * @return {?string} the avatar URL or null. + */ +MatrixClient.prototype.mxcUrlToHttp = + function(mxcUrl, width, height, resizeMethod, allowDirectLinks) { + return contentRepo.getHttpUriForMxc( + this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks, + ); +}; + +/** + * Sets a new status message for the user. The message may be null/falsey + * to clear the message. + * @param {string} newMessage The new message to set. + * @return {module:client.Promise} Resolves: to nothing + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype._unstable_setStatusMessage = function(newMessage) { + const type = "im.vector.user_status"; + return Promise.all(this.getRooms().map((room) => { + const isJoined = room.getMyMembership() === "join"; + const looksLikeDm = room.getInvitedAndJoinedMemberCount() === 2; + if (!isJoined || !looksLikeDm) { + return Promise.resolve(); + } + // Check power level separately as it's a bit more expensive. + const maySend = room.currentState.mayClientSendStateEvent(type, this); + if (!maySend) { + return Promise.resolve(); + } + return this.sendStateEvent(room.roomId, type, { + status: newMessage, + }, this.getUserId()); + })); +}; + +/** + * @param {Object} opts Options to apply + * @param {string} opts.presence One of "online", "offline" or "unavailable" + * @param {string} opts.status_msg The status message to attach. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + * @throws If 'presence' isn't a valid presence enum value. + */ +MatrixClient.prototype.setPresence = function(opts, callback) { + const path = utils.encodeUri("/presence/$userId/status", { + $userId: this.credentials.userId, + }); + + if (typeof opts === "string") { + opts = { presence: opts }; + } + + const validStates = ["offline", "online", "unavailable"]; + if (validStates.indexOf(opts.presence) == -1) { + throw new Error("Bad presence value: " + opts.presence); + } + return this._http.authedRequest( + callback, "PUT", path, undefined, opts, + ); +}; + +function _presenceList(callback, client, opts, method) { + const path = utils.encodeUri("/presence/list/$userId", { + $userId: client.credentials.userId, + }); + return client._http.authedRequest(callback, method, path, undefined, opts); +} + +/** +* Retrieve current user presence list. +* @param {module:client.callback} callback Optional. +* @return {module:client.Promise} Resolves: TODO +* @return {module:http-api.MatrixError} Rejects: with an error response. +*/ +MatrixClient.prototype.getPresenceList = function(callback) { + return _presenceList(callback, this, undefined, "GET"); +}; + +/** +* Add users to the current user presence list. +* @param {module:client.callback} callback Optional. +* @param {string[]} userIds +* @return {module:client.Promise} Resolves: TODO +* @return {module:http-api.MatrixError} Rejects: with an error response. +*/ +MatrixClient.prototype.inviteToPresenceList = function(callback, userIds) { + const opts = {"invite": userIds}; + return _presenceList(callback, this, opts, "POST"); +}; + +/** +* Drop users from the current user presence list. +* @param {module:client.callback} callback Optional. +* @param {string[]} userIds +* @return {module:client.Promise} Resolves: TODO +* @return {module:http-api.MatrixError} Rejects: with an error response. +**/ +MatrixClient.prototype.dropFromPresenceList = function(callback, userIds) { + const opts = {"drop": userIds}; + return _presenceList(callback, this, opts, "POST"); +}; + +/** + * Retrieve older messages from the given room and put them in the timeline. + * + * If this is called multiple times whilst a request is ongoing, the same + * Promise will be returned. If there was a problem requesting scrollback, there + * will be a small delay before another request can be made (to prevent tight-looping + * when there is no connection). + * + * @param {Room} room The room to get older messages in. + * @param {Integer} limit Optional. The maximum number of previous events to + * pull in. Default: 30. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: Room. If you are at the beginning + * of the timeline, Room.oldState.paginationToken will be + * null. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.scrollback = function(room, limit, callback) { + if (utils.isFunction(limit)) { + callback = limit; limit = undefined; + } + limit = limit || 30; + let timeToWaitMs = 0; + + let info = this._ongoingScrollbacks[room.roomId] || {}; + if (info.promise) { + return info.promise; + } else if (info.errorTs) { + const timeWaitedMs = Date.now() - info.errorTs; + timeToWaitMs = Math.max(SCROLLBACK_DELAY_MS - timeWaitedMs, 0); + } + + if (room.oldState.paginationToken === null) { + return Promise.resolve(room); // already at the start. + } + // attempt to grab more events from the store first + const numAdded = this.store.scrollback(room, limit).length; + if (numAdded === limit) { + // store contained everything we needed. + return Promise.resolve(room); + } + // reduce the required number of events appropriately + limit = limit - numAdded; + + const defer = Promise.defer(); + info = { + promise: defer.promise, + errorTs: null, + }; + const self = this; + // wait for a time before doing this request + // (which may be 0 in order not to special case the code paths) + Promise.delay(timeToWaitMs).then(function() { + return self._createMessagesRequest( + room.roomId, + room.oldState.paginationToken, + limit, + 'b'); + }).done(function(res) { + const matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self)); + if (res.state) { + const stateEvents = utils.map(res.state, _PojoToMatrixEventMapper(self)); + room.currentState.setUnknownStateEvents(stateEvents); + } + room.addEventsToTimeline(matrixEvents, true, room.getLiveTimeline()); + room.oldState.paginationToken = res.end; + if (res.chunk.length === 0) { + room.oldState.paginationToken = null; + } + self.store.storeEvents(room, matrixEvents, res.end, true); + self._ongoingScrollbacks[room.roomId] = null; + _resolve(callback, defer, room); + }, function(err) { + self._ongoingScrollbacks[room.roomId] = { + errorTs: Date.now(), + }; + _reject(callback, defer, err); + }); + this._ongoingScrollbacks[room.roomId] = info; + return defer.promise; +}; + +/** + * Get an EventTimeline for the given event + * + *

    If the EventTimelineSet object already has the given event in its store, the + * corresponding timeline will be returned. Otherwise, a /context request is + * made, and used to construct an EventTimeline. + * + * @param {EventTimelineSet} timelineSet The timelineSet to look for the event in + * @param {string} eventId The ID of the event to look for + * + * @return {module:client.Promise} Resolves: + * {@link module:models/event-timeline~EventTimeline} including the given + * event + */ +MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) { + // don't allow any timeline support unless it's been enabled. + if (!this.timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + + " parameter to true when creating MatrixClient to enable" + + " it."); + } + + if (timelineSet.getTimelineForEvent(eventId)) { + return Promise.resolve(timelineSet.getTimelineForEvent(eventId)); + } + + const path = utils.encodeUri( + "/rooms/$roomId/context/$eventId", { + $roomId: timelineSet.room.roomId, + $eventId: eventId, + }, + ); + + let params = undefined; + if (this._clientOpts.lazyLoadMembers) { + params = {filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER)}; + } + + // TODO: we should implement a backoff (as per scrollback()) to deal more + // nicely with HTTP errors. + const self = this; + const promise = + self._http.authedRequest(undefined, "GET", path, params, + ).then(function(res) { + if (!res.event) { + throw new Error("'event' not in '/context' result - homeserver too old?"); + } + + // by the time the request completes, the event might have ended up in + // the timeline. + if (timelineSet.getTimelineForEvent(eventId)) { + return timelineSet.getTimelineForEvent(eventId); + } + + // we start with the last event, since that's the point at which we + // have known state. + // events_after is already backwards; events_before is forwards. + res.events_after.reverse(); + const events = res.events_after + .concat([res.event]) + .concat(res.events_before); + const matrixEvents = utils.map(events, self.getEventMapper()); + + let timeline = timelineSet.getTimelineForEvent(matrixEvents[0].getId()); + if (!timeline) { + timeline = timelineSet.addTimeline(); + timeline.initialiseState(utils.map(res.state, + self.getEventMapper())); + timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end; + } else { + const stateEvents = utils.map(res.state, self.getEventMapper()); + timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(stateEvents); + } + timelineSet.addEventsToTimeline(matrixEvents, true, timeline, res.start); + + // there is no guarantee that the event ended up in "timeline" (we + // might have switched to a neighbouring timeline) - so check the + // room's index again. On the other hand, there's no guarantee the + // event ended up anywhere, if it was later redacted, so we just + // return the timeline we first thought of. + const tl = timelineSet.getTimelineForEvent(eventId) || timeline; + return tl; + }); + return promise; +}; + +/** + * Makes a request to /messages with the appropriate lazy loading filter set. + * XXX: if we do get rid of scrollback (as it's not used at the moment), + * we could inline this method again in paginateEventTimeline as that would + * then be the only call-site + * @param {string} roomId + * @param {string} fromToken + * @param {number} limit the maximum amount of events the retrieve + * @param {string} dir 'f' or 'b' + * @param {Filter} timelineFilter the timeline filter to pass + * @return {Promise} + */ +MatrixClient.prototype._createMessagesRequest = +function(roomId, fromToken, limit, dir, timelineFilter = undefined) { + const path = utils.encodeUri( + "/rooms/$roomId/messages", {$roomId: roomId}, + ); + if (limit === undefined) { + limit = 30; + } + const params = { + from: fromToken, + limit: limit, + dir: dir, + }; + + let filter = null; + if (this._clientOpts.lazyLoadMembers) { + // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER, + // so the timelineFilter doesn't get written into it below + filter = Object.assign({}, Filter.LAZY_LOADING_MESSAGES_FILTER); + } + if (timelineFilter) { + // 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()); + } + if (filter) { + params.filter = JSON.stringify(filter); + } + return this._http.authedRequest(undefined, "GET", path, params); +}; + +/** + * Take an EventTimeline, and back/forward-fill results. + * + * @param {module:models/event-timeline~EventTimeline} eventTimeline timeline + * object to be updated + * @param {Object} [opts] + * @param {bool} [opts.backwards = false] true to fill backwards, + * false to go forwards + * @param {number} [opts.limit = 30] number of events to request + * + * @return {module:client.Promise} Resolves to a boolean: false if there are no + * events and we reached either end of the timeline; else true. + */ +MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { + const isNotifTimeline = (eventTimeline.getTimelineSet() === this._notifTimelineSet); + + // TODO: we should implement a backoff (as per scrollback()) to deal more + // nicely with HTTP errors. + opts = opts || {}; + const backwards = opts.backwards || false; + + if (isNotifTimeline) { + if (!backwards) { + throw new Error("paginateNotifTimeline can only paginate backwards"); + } + } + + const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; + + const token = eventTimeline.getPaginationToken(dir); + if (!token) { + // no token - no results. + return Promise.resolve(false); + } + + const pendingRequest = eventTimeline._paginationRequests[dir]; + + if (pendingRequest) { + // already a request in progress - return the existing promise + return pendingRequest; + } + + let path, params, promise; + const self = this; + + if (isNotifTimeline) { + path = "/notifications"; + params = { + limit: ('limit' in opts) ? opts.limit : 30, + only: 'highlight', + }; + + if (token && token !== "end") { + params.from = token; + } + + promise = this._http.authedRequest( + undefined, "GET", path, params, undefined, + ).then(function(res) { + const token = res.next_token; + const matrixEvents = []; + + for (let i = 0; i < res.notifications.length; i++) { + const notification = res.notifications[i]; + const event = self.getEventMapper()(notification.event); + event.setPushActions( + PushProcessor.actionListToActionsObject(notification.actions), + ); + event.event.room_id = notification.room_id; // XXX: gutwrenching + matrixEvents[i] = event; + } + + eventTimeline.getTimelineSet() + .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && !res.next_token) { + eventTimeline.setPaginationToken(null, dir); + } + return res.next_token ? true : false; + }).finally(function() { + eventTimeline._paginationRequests[dir] = null; + }); + eventTimeline._paginationRequests[dir] = promise; + } else { + const room = this.getRoom(eventTimeline.getRoomId()); + if (!room) { + throw new Error("Unknown room " + eventTimeline.getRoomId()); + } + + promise = this._createMessagesRequest( + eventTimeline.getRoomId(), + token, + opts.limit, + dir, + eventTimeline.getFilter()); + promise.then(function(res) { + if (res.state) { + const roomState = eventTimeline.getState(dir); + const stateEvents = utils.map(res.state, self.getEventMapper()); + roomState.setUnknownStateEvents(stateEvents); + } + const token = res.end; + const matrixEvents = utils.map(res.chunk, self.getEventMapper()); + eventTimeline.getTimelineSet() + .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && res.end == res.start) { + eventTimeline.setPaginationToken(null, dir); + } + return res.end != res.start; + }).finally(function() { + eventTimeline._paginationRequests[dir] = null; + }); + eventTimeline._paginationRequests[dir] = promise; + } + + return promise; +}; + +/** + * Reset the notifTimelineSet entirely, paginating in some historical notifs as + * a starting point for subsequent pagination. + */ +MatrixClient.prototype.resetNotifTimelineSet = function() { + if (!this._notifTimelineSet) { + return; + } + + // FIXME: This thing is a total hack, and results in duplicate events being + // added to the timeline both from /sync and /notifications, and lots of + // slow and wasteful processing and pagination. The correct solution is to + // extend /messages or /search or something to filter on notifications. + + // use the fictitious token 'end'. in practice we would ideally give it + // the oldest backwards pagination token from /sync, but /sync doesn't + // know about /notifications, so we have no choice but to start paginating + // from the current point in time. This may well overlap with historical + // notifs which are then inserted into the timeline by /sync responses. + this._notifTimelineSet.resetLiveTimeline('end', null); + + // we could try to paginate a single event at this point in order to get + // a more valid pagination token, but it just ends up with an out of order + // timeline. given what a mess this is and given we're going to have duplicate + // events anyway, just leave it with the dummy token for now. + /* + this.paginateNotifTimeline(this._notifTimelineSet.getLiveTimeline(), { + backwards: true, + limit: 1 + }); + */ +}; + +/** + * Peek into a room and receive updates about the room. This only works if the + * history visibility for the room is world_readable. + * @param {String} roomId The room to attempt to peek into. + * @return {module:client.Promise} Resolves: Room object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.peekInRoom = function(roomId) { + if (this._peekSync) { + this._peekSync.stopPeeking(); + } + this._peekSync = new SyncApi(this, this._clientOpts); + return this._peekSync.peek(roomId); +}; + +/** + * Stop any ongoing room peeking. + */ +MatrixClient.prototype.stopPeeking = function() { + if (this._peekSync) { + this._peekSync.stopPeeking(); + this._peekSync = null; + } +}; + +/** + * Set r/w flags for guest access in a room. + * @param {string} roomId The room to configure guest access in. + * @param {Object} opts Options + * @param {boolean} opts.allowJoin True to allow guests to join this room. This + * implicitly gives guests write access. If false or not given, guests are + * explicitly forbidden from joining the room. + * @param {boolean} opts.allowRead True to set history visibility to + * be world_readable. This gives guests read access *from this point forward*. + * If false or not given, history visibility is not modified. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setGuestAccess = function(roomId, opts) { + const writePromise = this.sendStateEvent(roomId, "m.room.guest_access", { + guest_access: opts.allowJoin ? "can_join" : "forbidden", + }); + + let readPromise = Promise.resolve(); + if (opts.allowRead) { + readPromise = this.sendStateEvent(roomId, "m.room.history_visibility", { + history_visibility: "world_readable", + }); + } + + return Promise.all([readPromise, writePromise]); +}; + +// Registration/Login operations +// ============================= + +/** + * Requests an email verification token for the purposes of registration. + * This API proxies the Identity Server /validate/email/requestToken API, + * adding registration-specific behaviour. Specifically, if an account with + * the given email address already exists, it will either send an email + * to the address informing them of this or return M_THREEPID_IN_USE + * (which one is up to the Home Server). + * + * requestEmailToken calls the equivalent API directly on the ID server, + * therefore bypassing the registration-specific logic. + * + * Parameters and return value are as for requestEmailToken + + * @param {string} email As requestEmailToken + * @param {string} clientSecret As requestEmailToken + * @param {number} sendAttempt As requestEmailToken + * @param {string} nextLink As requestEmailToken + * @return {module:client.Promise} Resolves: As requestEmailToken + */ +MatrixClient.prototype.requestRegisterEmailToken = function(email, clientSecret, + sendAttempt, nextLink) { + return this._requestTokenFromEndpoint( + "/register/email/requestToken", + { + email: email, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }, + ); +}; + +/** + * Requests a text message verification token for the purposes of registration. + * This API proxies the Identity Server /validate/msisdn/requestToken API, + * adding registration-specific behaviour, as with requestRegisterEmailToken. + * + * @param {string} phoneCountry The ISO 3166-1 alpha-2 code for the country in which + * phoneNumber should be parsed relative to. + * @param {string} phoneNumber The phone number, in national or international format + * @param {string} clientSecret As requestEmailToken + * @param {number} sendAttempt As requestEmailToken + * @param {string} nextLink As requestEmailToken + * @return {module:client.Promise} Resolves: As requestEmailToken + */ +MatrixClient.prototype.requestRegisterMsisdnToken = function(phoneCountry, phoneNumber, + clientSecret, sendAttempt, nextLink) { + return this._requestTokenFromEndpoint( + "/register/msisdn/requestToken", + { + country: phoneCountry, + phone_number: phoneNumber, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }, + ); +}; + +/** + * Requests an email verification token for the purposes of adding a + * third party identifier to an account. + * This API proxies the Identity Server /validate/email/requestToken API, + * adding specific behaviour for the addition of email addresses to an + * account. Specifically, if an account with + * the given email address already exists, it will either send an email + * to the address informing them of this or return M_THREEPID_IN_USE + * (which one is up to the Home Server). + * + * requestEmailToken calls the equivalent API directly on the ID server, + * therefore bypassing the email addition specific logic. + * + * @param {string} email As requestEmailToken + * @param {string} clientSecret As requestEmailToken + * @param {number} sendAttempt As requestEmailToken + * @param {string} nextLink As requestEmailToken + * @return {module:client.Promise} Resolves: As requestEmailToken + */ +MatrixClient.prototype.requestAdd3pidEmailToken = function(email, clientSecret, + sendAttempt, nextLink) { + return this._requestTokenFromEndpoint( + "/account/3pid/email/requestToken", + { + email: email, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }, + ); +}; + +/** + * Requests a text message verification token for the purposes of adding a + * third party identifier to an account. + * This API proxies the Identity Server /validate/email/requestToken API, + * adding specific behaviour for the addition of phone numbers to an + * account, as requestAdd3pidEmailToken. + * + * @param {string} phoneCountry As requestRegisterMsisdnToken + * @param {string} phoneNumber As requestRegisterMsisdnToken + * @param {string} clientSecret As requestEmailToken + * @param {number} sendAttempt As requestEmailToken + * @param {string} nextLink As requestEmailToken + * @return {module:client.Promise} Resolves: As requestEmailToken + */ +MatrixClient.prototype.requestAdd3pidMsisdnToken = function(phoneCountry, phoneNumber, + clientSecret, sendAttempt, nextLink) { + return this._requestTokenFromEndpoint( + "/account/3pid/msisdn/requestToken", + { + country: phoneCountry, + phone_number: phoneNumber, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }, + ); +}; + +/** + * Requests an email verification token for the purposes of resetting + * the password on an account. + * This API proxies the Identity Server /validate/email/requestToken API, + * adding specific behaviour for the password resetting. Specifically, + * if no account with the given email address exists, it may either + * return M_THREEPID_NOT_FOUND or send an email + * to the address informing them of this (which one is up to the Home Server). + * + * requestEmailToken calls the equivalent API directly on the ID server, + * therefore bypassing the password reset specific logic. + * + * @param {string} email As requestEmailToken + * @param {string} clientSecret As requestEmailToken + * @param {number} sendAttempt As requestEmailToken + * @param {string} nextLink As requestEmailToken + * @param {module:client.callback} callback Optional. As requestEmailToken + * @return {module:client.Promise} Resolves: As requestEmailToken + */ +MatrixClient.prototype.requestPasswordEmailToken = function(email, clientSecret, + sendAttempt, nextLink) { + return this._requestTokenFromEndpoint( + "/account/password/email/requestToken", + { + email: email, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }, + ); +}; + +/** + * Requests a text message verification token for the purposes of resetting + * the password on an account. + * This API proxies the Identity Server /validate/email/requestToken API, + * adding specific behaviour for the password resetting, as requestPasswordEmailToken. + * + * @param {string} phoneCountry As requestRegisterMsisdnToken + * @param {string} phoneNumber As requestRegisterMsisdnToken + * @param {string} clientSecret As requestEmailToken + * @param {number} sendAttempt As requestEmailToken + * @param {string} nextLink As requestEmailToken + * @return {module:client.Promise} Resolves: As requestEmailToken + */ +MatrixClient.prototype.requestPasswordMsisdnToken = function(phoneCountry, phoneNumber, + clientSecret, sendAttempt, nextLink) { + return this._requestTokenFromEndpoint( + "/account/password/msisdn/requestToken", + { + country: phoneCountry, + phone_number: phoneNumber, + client_secret: clientSecret, + send_attempt: sendAttempt, + next_link: nextLink, + }, + ); +}; + +/** + * Internal utility function for requesting validation tokens from usage-specific + * requestToken endpoints. + * + * @param {string} endpoint The endpoint to send the request to + * @param {object} params Parameters for the POST request + * @return {module:client.Promise} Resolves: As requestEmailToken + */ +MatrixClient.prototype._requestTokenFromEndpoint = function(endpoint, params) { + const id_server_url = url.parse(this.idBaseUrl); + if (id_server_url.host === null) { + throw new Error("Invalid ID server URL: " + this.idBaseUrl); + } + + const postParams = Object.assign({}, params, { + id_server: id_server_url.host, + }); + return this._http.request( + undefined, "POST", endpoint, undefined, + postParams, + ); +}; + + +// Push operations +// =============== + +/** + * Get the room-kind push rule associated with a room. + * @param {string} scope "global" or device-specific. + * @param {string} roomId the id of the room. + * @return {object} the rule or undefined. + */ +MatrixClient.prototype.getRoomPushRule = function(scope, roomId) { + // There can be only room-kind push rule per room + // and its id is the room id. + if (this.pushRules) { + for (let i = 0; i < this.pushRules[scope].room.length; i++) { + const rule = this.pushRules[scope].room[i]; + if (rule.rule_id === roomId) { + return rule; + } + } + } else { + throw new Error( + "SyncApi.sync() must be done before accessing to push rules.", + ); + } +}; + +/** + * Set a room-kind muting push rule in a room. + * The operation also updates MatrixClient.pushRules at the end. + * @param {string} scope "global" or device-specific. + * @param {string} roomId the id of the room. + * @param {string} mute the mute state. + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setRoomMutePushRule = function(scope, roomId, mute) { + const self = this; + let deferred, hasDontNotifyRule; + + // Get the existing room-kind push rule if any + const roomPushRule = this.getRoomPushRule(scope, roomId); + if (roomPushRule) { + if (0 <= roomPushRule.actions.indexOf("dont_notify")) { + hasDontNotifyRule = true; + } + } + + if (!mute) { + // Remove the rule only if it is a muting rule + if (hasDontNotifyRule) { + deferred = this.deletePushRule(scope, "room", roomPushRule.rule_id); + } + } else { + if (!roomPushRule) { + deferred = this.addPushRule(scope, "room", roomId, { + actions: ["dont_notify"], + }); + } else if (!hasDontNotifyRule) { + // Remove the existing one before setting the mute push rule + // This is a workaround to SYN-590 (Push rule update fails) + deferred = Promise.defer(); + this.deletePushRule(scope, "room", roomPushRule.rule_id) + .done(function() { + self.addPushRule(scope, "room", roomId, { + actions: ["dont_notify"], + }).done(function() { + deferred.resolve(); + }, function(err) { + deferred.reject(err); + }); + }, function(err) { + deferred.reject(err); + }); + + deferred = deferred.promise; + } + } + + if (deferred) { + // Update this.pushRules when the operation completes + const ruleRefreshDeferred = Promise.defer(); + deferred.done(function() { + self.getPushRules().done(function(result) { + self.pushRules = result; + ruleRefreshDeferred.resolve(); + }, function(err) { + ruleRefreshDeferred.reject(err); + }); + }, function(err) { + // Update it even if the previous operation fails. This can help the + // app to recover when push settings has been modifed from another client + self.getPushRules().done(function(result) { + self.pushRules = result; + ruleRefreshDeferred.reject(err); + }, function(err2) { + ruleRefreshDeferred.reject(err); + }); + }); + return ruleRefreshDeferred.promise; + } +}; + +// Search +// ====== + +/** + * Perform a server-side search for messages containing the given text. + * @param {Object} opts Options for the search. + * @param {string} opts.query The text to query. + * @param {string=} opts.keys The keys to search on. Defaults to all keys. One + * of "content.body", "content.name", "content.topic". + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.searchMessageText = function(opts, callback) { + const roomEvents = { + search_term: opts.query, + }; + + if ('keys' in opts) { + roomEvents.keys = opts.keys; + } + + return this.search({ + body: { + search_categories: { + room_events: roomEvents, + }, + }, + }, callback); +}; + +/** + * Perform a server-side search for room events. + * + * The returned promise resolves to an object containing the fields: + * + * * {number} count: estimate of the number of results + * * {string} next_batch: token for back-pagination; if undefined, there are + * no more results + * * {Array} highlights: a list of words to highlight from the stemming + * algorithm + * * {Array} results: a list of results + * + * Each entry in the results list is a {module:models/search-result.SearchResult}. + * + * @param {Object} opts + * @param {string} opts.term the term to search for + * @param {Object} opts.filter a JSON filter object to pass in the request + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.searchRoomEvents = function(opts) { + // TODO: support groups + + const body = { + search_categories: { + room_events: { + search_term: opts.term, + filter: opts.filter, + order_by: "recent", + event_context: { + before_limit: 1, + after_limit: 1, + include_profile: true, + }, + }, + }, + }; + + const searchResults = { + _query: body, + results: [], + highlights: [], + }; + + return this.search({body: body}).then( + this._processRoomEventsSearch.bind(this, searchResults), + ); +}; + +/** + * Take a result from an earlier searchRoomEvents call, and backfill results. + * + * @param {object} searchResults the results object to be updated + * @return {module:client.Promise} Resolves: updated result object + * @return {Error} Rejects: with an error response. + */ +MatrixClient.prototype.backPaginateRoomEventsSearch = function(searchResults) { + // TODO: we should implement a backoff (as per scrollback()) to deal more + // nicely with HTTP errors. + + if (!searchResults.next_batch) { + return Promise.reject(new Error("Cannot backpaginate event search any further")); + } + + if (searchResults.pendingRequest) { + // already a request in progress - return the existing promise + return searchResults.pendingRequest; + } + + const searchOpts = { + body: searchResults._query, + next_batch: searchResults.next_batch, + }; + + const promise = this.search(searchOpts).then( + this._processRoomEventsSearch.bind(this, searchResults), + ).finally(function() { + searchResults.pendingRequest = null; + }); + searchResults.pendingRequest = promise; + + return promise; +}; + +/** + * helper for searchRoomEvents and backPaginateRoomEventsSearch. Processes the + * response from the API call and updates the searchResults + * + * @param {Object} searchResults + * @param {Object} response + * @return {Object} searchResults + * @private + */ +MatrixClient.prototype._processRoomEventsSearch = function(searchResults, response) { + const room_events = response.search_categories.room_events; + + searchResults.count = room_events.count; + searchResults.next_batch = room_events.next_batch; + + // combine the highlight list with our existing list; build an object + // to avoid O(N^2) fail + const highlights = {}; + room_events.highlights.forEach(function(hl) { + highlights[hl] = 1; + }); + searchResults.highlights.forEach(function(hl) { + highlights[hl] = 1; + }); + + // turn it back into a list. + searchResults.highlights = Object.keys(highlights); + + // append the new results to our existing results + for (let i = 0; i < room_events.results.length; i++) { + const sr = SearchResult.fromJson(room_events.results[i], this.getEventMapper()); + searchResults.results.push(sr); + } + return searchResults; +}; + + +/** + * Populate the store with rooms the user has left. + * @return {module:client.Promise} Resolves: TODO - Resolved when the rooms have + * been added to the data store. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.syncLeftRooms = function() { + // Guard against multiple calls whilst ongoing and multiple calls post success + if (this._syncedLeftRooms) { + return Promise.resolve([]); // don't call syncRooms again if it succeeded. + } + if (this._syncLeftRoomsPromise) { + return this._syncLeftRoomsPromise; // return the ongoing request + } + const self = this; + const syncApi = new SyncApi(this, this._clientOpts); + this._syncLeftRoomsPromise = syncApi.syncLeftRooms(); + + // cleanup locks + this._syncLeftRoomsPromise.then(function(res) { + logger.log("Marking success of sync left room request"); + self._syncedLeftRooms = true; // flip the bit on success + }).finally(function() { + self._syncLeftRoomsPromise = null; // cleanup ongoing request state + }); + + return this._syncLeftRoomsPromise; +}; + +// Filters +// ======= + +/** + * Create a new filter. + * @param {Object} content The HTTP body for the request + * @return {Filter} Resolves to a Filter object. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.createFilter = function(content) { + const self = this; + const path = utils.encodeUri("/user/$userId/filter", { + $userId: this.credentials.userId, + }); + return this._http.authedRequest( + undefined, "POST", path, undefined, content, + ).then(function(response) { + // persist the filter + const filter = Filter.fromJson( + self.credentials.userId, response.filter_id, content, + ); + self.store.storeFilter(filter); + return filter; + }); +}; + +/** + * Retrieve a filter. + * @param {string} userId The user ID of the filter owner + * @param {string} filterId The filter ID to retrieve + * @param {boolean} allowCached True to allow cached filters to be returned. + * Default: True. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.getFilter = function(userId, filterId, allowCached) { + if (allowCached) { + const filter = this.store.getFilter(userId, filterId); + if (filter) { + return Promise.resolve(filter); + } + } + + const self = this; + const path = utils.encodeUri("/user/$userId/filter/$filterId", { + $userId: userId, + $filterId: filterId, + }); + + return this._http.authedRequest( + undefined, "GET", path, undefined, undefined, + ).then(function(response) { + // persist the filter + const filter = Filter.fromJson( + userId, filterId, response, + ); + self.store.storeFilter(filter); + return filter; + }); +}; + +/** + * @param {string} filterName + * @param {Filter} filter + * @return {Promise} Filter ID + */ +MatrixClient.prototype.getOrCreateFilter = function(filterName, filter) { + const filterId = this.store.getFilterIdByName(filterName); + let promise = Promise.resolve(); + const self = this; + + if (filterId) { + // check that the existing filter matches our expectations + promise = self.getFilter(self.credentials.userId, + filterId, true, + ).then(function(existingFilter) { + const oldDef = existingFilter.getDefinition(); + const newDef = filter.getDefinition(); + + if (utils.deepCompare(oldDef, newDef)) { + // super, just use that. + // debuglog("Using existing filter ID %s: %s", filterId, + // JSON.stringify(oldDef)); + return Promise.resolve(filterId); + } + // debuglog("Existing filter ID %s: %s; new filter: %s", + // filterId, JSON.stringify(oldDef), JSON.stringify(newDef)); + self.store.setFilterIdByName(filterName, undefined); + return undefined; + }, function(error) { + // Synapse currently returns the following when the filter cannot be found: + // { + // errcode: "M_UNKNOWN", + // name: "M_UNKNOWN", + // message: "No row found", + // data: Object, httpStatus: 404 + // } + if (error.httpStatus === 404 && + (error.errcode === "M_UNKNOWN" || error.errcode === "M_NOT_FOUND")) { + // Clear existing filterId from localStorage + // if it no longer exists on the server + self.store.setFilterIdByName(filterName, undefined); + // Return a undefined value for existingId further down the promise chain + return undefined; + } else { + throw error; + } + }); + } + + return promise.then(function(existingId) { + if (existingId) { + return existingId; + } + + // create a new filter + return self.createFilter(filter.getDefinition(), + ).then(function(createdFilter) { + // debuglog("Created new filter ID %s: %s", createdFilter.filterId, + // JSON.stringify(createdFilter.getDefinition())); + self.store.setFilterIdByName(filterName, createdFilter.filterId); + return createdFilter.filterId; + }); + }); +}; + + +/** + * Gets a bearer token from the Home Server that the user can + * present to a third party in order to prove their ownership + * of the Matrix account they are logged into. + * @return {module:client.Promise} Resolves: Token object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.getOpenIdToken = function() { + const path = utils.encodeUri("/user/$userId/openid/request_token", { + $userId: this.credentials.userId, + }); + + return this._http.authedRequest( + undefined, "POST", path, undefined, {}, + ); +}; + + +// VoIP operations +// =============== + +/** + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.turnServer = function(callback) { + return this._http.authedRequest(callback, "GET", "/voip/turnServer"); +}; + +/** + * Get the TURN servers for this home server. + * @return {Array} The servers or an empty list. + */ +MatrixClient.prototype.getTurnServers = function() { + return this._turnServers || []; +}; + +// Higher level APIs +// ================= + +// TODO: stuff to handle: +// local echo +// event dup suppression? - apparently we should still be doing this +// tracking current display name / avatar per-message +// pagination +// re-sending (including persisting pending messages to be sent) +// - Need a nice way to callback the app for arbitrary events like +// displayname changes +// due to ambiguity (or should this be on a chat-specific layer)? +// reconnect after connectivity outages + + +/** + * High level helper method to begin syncing and poll for new events. To listen for these + * events, add a listener for {@link module:client~MatrixClient#event:"event"} + * via {@link module:client~MatrixClient#on}. Alternatively, listen for specific + * state change events. + * @param {Object=} opts Options to apply when syncing. + * @param {Number=} opts.initialSyncLimit The event limit= to apply + * to initial sync. Default: 8. + * @param {Boolean=} opts.includeArchivedRooms True to put archived=true + * on the /initialSync request. Default: false. + * @param {Boolean=} opts.resolveInvitesToProfiles True to do /profile requests + * on every invite event if the displayname/avatar_url is not known for this user ID. + * Default: false. + * + * @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 {Number=} opts.pollTimeout The number of milliseconds to wait on /sync. + * Default: 30000 (30 seconds). + * + * @param {Filter=} opts.filter The filter to apply to /sync calls. This will override + * the opts.initialSyncLimit, which would normally result in a timeline limit filter. + * + * @param {Boolean=} opts.disablePresence True to perform syncing without automatically + * updating presence. + * @param {Boolean=} opts.lazyLoadMembers True to not load all membership events during + * initial sync but fetch them when needed by calling `loadOutOfBandMembers` + * This will override the filter option at this moment. + */ +MatrixClient.prototype.startClient = async function(opts) { + if (this.clientRunning) { + // client is already running. + return; + } + this.clientRunning = true; + // backwards compat for when 'opts' was 'historyLen'. + if (typeof opts === "number") { + opts = { + initialSyncLimit: opts, + }; + } + + if (this._crypto) { + this._crypto.uploadDeviceKeys().done(); + this._crypto.start(); + } + + // periodically poll for turn servers if we support voip + checkTurnServers(this); + + if (this._syncApi) { + // This shouldn't happen since we thought the client was not running + logger.error("Still have sync object whilst not running: stopping old one"); + this._syncApi.stop(); + } + + // shallow-copy the opts dict before modifying and storing it + opts = Object.assign({}, opts); + + opts.crypto = this._crypto; + opts.canResetEntireTimeline = (roomId) => { + if (!this._canResetTimelineCallback) { + return false; + } + return this._canResetTimelineCallback(roomId); + }; + this._clientOpts = opts; + this._syncApi = new SyncApi(this, opts); + this._syncApi.sync(); +}; + +/** + * store client options with boolean/string/numeric values + * to know in the next session what flags the sync data was + * created with (e.g. lazy loading) + * @param {object} opts the complete set of client options + * @return {Promise} for store operation */ +MatrixClient.prototype._storeClientOptions = function() { + const primTypes = ["boolean", "string", "number"]; + const serializableOpts = Object.entries(this._clientOpts) + .filter(([key, value]) => { + return primTypes.includes(typeof value); + }) + .reduce((obj, [key, value]) => { + obj[key] = value; + return obj; + }, {}); + return this.store.storeClientOptions(serializableOpts); +}; + +/** + * High level helper method to stop the client from polling and allow a + * clean shutdown. + */ +MatrixClient.prototype.stopClient = function() { + logger.log('stopping MatrixClient'); + + this.clientRunning = false; + // TODO: f.e. Room => self.store.storeRoom(room) ? + if (this._syncApi) { + this._syncApi.stop(); + this._syncApi = null; + } + if (this._crypto) { + this._crypto.stop(); + } + if (this._peekSync) { + this._peekSync.stopPeeking(); + } + global.clearTimeout(this._checkTurnServersTimeoutID); +}; + +/* + * Query the server to see if it support members lazy loading + * @return {Promise} true if server supports lazy loading + */ +MatrixClient.prototype.doesServerSupportLazyLoading = async function() { + if (this._serverSupportsLazyLoading === null) { + const response = await this._http.request( + undefined, // callback + "GET", "/_matrix/client/versions", + undefined, // queryParams + undefined, // data + { + prefix: '', + }, + ); + + const versions = response["versions"]; + const unstableFeatures = response["unstable_features"]; + + this._serverSupportsLazyLoading = + (versions && versions.includes("r0.5.0")) + || (unstableFeatures && unstableFeatures["m.lazy_load_members"]); + } + return this._serverSupportsLazyLoading; +}; + +/* + * Get if lazy loading members is being used. + * @return {boolean} Whether or not members are lazy loaded by this client + */ +MatrixClient.prototype.hasLazyLoadMembersEnabled = function() { + return !!this._clientOpts.lazyLoadMembers; +}; + +/* + * Set a function which is called when /sync returns a 'limited' response. + * It 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, e.g because the client is + * actively viewing events in this room. + * Default: returns false. + * @param {Function} cb The callback which will be invoked. + */ +MatrixClient.prototype.setCanResetTimelineCallback = function(cb) { + this._canResetTimelineCallback = cb; +}; + +/** + * Get the callback set via `setCanResetTimelineCallback`. + * @return {?Function} The callback or null + */ +MatrixClient.prototype.getCanResetTimelineCallback = function() { + return this._canResetTimelineCallback; +}; + +/** + * Returns relations for a given event. Handles encryption transparently, + * with the caveat that the amount of events returned might be 0, even though you get a nextBatch. + * When the returned promise resolves, all messages should have finished trying to decrypt. + * @param {string} roomId the room of the event + * @param {string} eventId the id of the event + * @param {string} relationType the rel_type of the relations requested + * @param {string} eventType the event type of the relations requested + * @param {Object} opts options with optional values for the request. + * @param {Object} opts.from the pagination token returned from a previous request as `nextBatch` to return following relations. + * @return {Object} an object with `events` as `MatrixEvent[]` and optionally `nextBatch` if more relations are available. + */ +MatrixClient.prototype.relations = +async function(roomId, eventId, relationType, eventType, opts = {}) { + const fetchedEventType = _getEncryptedIfNeededEventType(this, roomId, eventType); + const result = await this.fetchRelations( + roomId, + eventId, + relationType, + fetchedEventType, + opts); + const mapper = this.getEventMapper(); + let originalEvent; + if (result.original_event) { + originalEvent = mapper(result.original_event); + } + let events = result.chunk.map(mapper); + if (fetchedEventType === "m.room.encrypted") { + const allEvents = originalEvent ? events.concat(originalEvent) : events; + await Promise.all(allEvents.map(e => { + return new Promise(resolve => e.once("Event.decrypted", resolve)); + })); + events = events.filter(e => e.getType() === eventType); + } + return { + originalEvent, + events, + nextBatch: result.next_batch, + }; +}; + +function setupCallEventHandler(client) { + const candidatesByCall = { + // callId: [Candidate] + }; + + // Maintain a buffer of events before the client has synced for the first time. + // This buffer will be inspected to see if we should send incoming call + // notifications. It needs to be buffered to correctly determine if an + // incoming call has had a matching answer/hangup. + let callEventBuffer = []; + let isClientPrepared = false; + client.on("sync", function(state) { + if (state === "PREPARED") { + isClientPrepared = true; + const ignoreCallIds = {}; // Set + // inspect the buffer and mark all calls which have been answered + // or hung up before passing them to the call event handler. + for (let i = callEventBuffer.length - 1; i >= 0; i--) { + const ev = callEventBuffer[i]; + if (ev.getType() === "m.call.answer" || + ev.getType() === "m.call.hangup") { + ignoreCallIds[ev.getContent().call_id] = "yep"; + } + } + // now loop through the buffer chronologically and inject them + callEventBuffer.forEach(function(e) { + if (ignoreCallIds[e.getContent().call_id]) { + // This call has previously been ansered or hung up: ignore it + return; + } + callEventHandler(e); + }); + callEventBuffer = []; + } + }); + + client.on("event", onEvent); + + function onEvent(event) { + if (event.getType().indexOf("m.call.") !== 0) { + // not a call event + if (event.isBeingDecrypted() || event.isDecryptionFailure()) { + // not *yet* a call event, but might become one... + event.once("Event.decrypted", onEvent); + } + return; + } + if (!isClientPrepared) { + callEventBuffer.push(event); + return; + } + callEventHandler(event); + } + + function callEventHandler(event) { + const content = event.getContent(); + let call = content.call_id ? client.callList[content.call_id] : undefined; + let i; + //console.log("RECV %s content=%s", event.getType(), JSON.stringify(content)); + + if (event.getType() === "m.call.invite") { + if (event.getSender() === client.credentials.userId) { + return; // ignore invites you send + } + + if (event.getAge() > content.lifetime) { + return; // expired call + } + + if (call && call.state === "ended") { + return; // stale/old invite event + } + if (call) { + logger.log( + "WARN: Already have a MatrixCall with id %s but got an " + + "invite. Clobbering.", + content.call_id, + ); + } + + call = webRtcCall.createNewMatrixCall(client, event.getRoomId(), { + forceTURN: client._forceTURN, + }); + if (!call) { + logger.log( + "Incoming call ID " + content.call_id + " but this client " + + "doesn't support WebRTC", + ); + // don't hang up the call: there could be other clients + // connected that do support WebRTC and declining the + // the call on their behalf would be really annoying. + return; + } + + call.callId = content.call_id; + call._initWithInvite(event); + client.callList[call.callId] = call; + + // if we stashed candidate events for that call ID, play them back now + if (candidatesByCall[call.callId]) { + for (i = 0; i < candidatesByCall[call.callId].length; i++) { + call._gotRemoteIceCandidate( + candidatesByCall[call.callId][i], + ); + } + } + + // Were we trying to call that user (room)? + let existingCall; + const existingCalls = utils.values(client.callList); + for (i = 0; i < existingCalls.length; ++i) { + const thisCall = existingCalls[i]; + if (call.roomId === thisCall.roomId && + thisCall.direction === 'outbound' && + (["wait_local_media", "create_offer", "invite_sent"].indexOf( + thisCall.state) !== -1)) { + existingCall = thisCall; + break; + } + } + + if (existingCall) { + // If we've only got to wait_local_media or create_offer and + // we've got an invite, pick the incoming call because we know + // we haven't sent our invite yet otherwise, pick whichever + // call has the lowest call ID (by string comparison) + if (existingCall.state === 'wait_local_media' || + existingCall.state === 'create_offer' || + existingCall.callId > call.callId) { + logger.log( + "Glare detected: answering incoming call " + call.callId + + " and canceling outgoing call " + existingCall.callId, + ); + existingCall._replacedBy(call); + call.answer(); + } else { + logger.log( + "Glare detected: rejecting incoming call " + call.callId + + " and keeping outgoing call " + existingCall.callId, + ); + call.hangup(); + } + } else { + client.emit("Call.incoming", call); + } + } else if (event.getType() === 'm.call.answer') { + if (!call) { + return; + } + if (event.getSender() === client.credentials.userId) { + if (call.state === 'ringing') { + call._onAnsweredElsewhere(content); + } + } else { + call._receivedAnswer(content); + } + } else if (event.getType() === 'm.call.candidates') { + if (event.getSender() === client.credentials.userId) { + return; + } + if (!call) { + // store the candidates; we may get a call eventually. + if (!candidatesByCall[content.call_id]) { + candidatesByCall[content.call_id] = []; + } + candidatesByCall[content.call_id] = candidatesByCall[ + content.call_id + ].concat(content.candidates); + } else { + for (i = 0; i < content.candidates.length; i++) { + call._gotRemoteIceCandidate(content.candidates[i]); + } + } + } else if (event.getType() === 'm.call.hangup') { + // 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) { + // if not live, store the fact that the call has ended because + // we're probably getting events backwards so + // the hangup will come before the invite + call = webRtcCall.createNewMatrixCall(client, event.getRoomId()); + if (call) { + call.callId = content.call_id; + call._initWithHangup(event); + client.callList[content.call_id] = call; + } + } else { + if (call.state !== 'ended') { + call._onHangupReceived(content); + delete client.callList[content.call_id]; + } + } + } + } +} + +function checkTurnServers(client) { + if (!client._supportsVoip) { + return; + } + if (client.isGuest()) { + return; // guests can't access TURN servers + } + + client.turnServer().done(function(res) { + if (res.uris) { + logger.log("Got TURN URIs: " + res.uris + " refresh in " + + res.ttl + " secs"); + // map the response to a format that can be fed to + // RTCPeerConnection + const servers = { + urls: res.uris, + username: res.username, + credential: res.password, + }; + client._turnServers = [servers]; + // re-fetch when we're about to reach the TTL + client._checkTurnServersTimeoutID = setTimeout(() => { + checkTurnServers(client); + }, (res.ttl || (60 * 60)) * 1000 * 0.9); + } + }, function(err) { + logger.error("Failed to get TURN URIs"); + client._checkTurnServersTimeoutID = + setTimeout(function() { + checkTurnServers(client); +}, 60000); + }); +} + +function _reject(callback, defer, err) { + if (callback) { + callback(err); + } + defer.reject(err); +} + +function _resolve(callback, defer, res) { + if (callback) { + callback(null, res); + } + defer.resolve(res); +} + +function _PojoToMatrixEventMapper(client) { + function mapper(plainOldJsObject) { + const event = new MatrixEvent(plainOldJsObject); + if (event.isEncrypted()) { + client.reEmitter.reEmit(event, [ + "Event.decrypted", + ]); + event.attemptDecryption(client._crypto); + } + const room = client.getRoom(event.getRoomId()); + if (room) { + room.reEmitter.reEmit(event, ["Event.replaced"]); + } + return event; + } + return mapper; +} + +/** + * @return {Function} + */ +MatrixClient.prototype.getEventMapper = function() { + return _PojoToMatrixEventMapper(this); +}; + +// Identity Server Operations +// ========================== + +/** + * Generates a random string suitable for use as a client secret. This + * method is experimental and may change. + * @return {string} A new client secret + */ +MatrixClient.prototype.generateClientSecret = function() { + return randomString(32); +}; + +/** */ +module.exports.MatrixClient = MatrixClient; +/** */ +module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED; + +// MatrixClient Event JSDocs + +/** + * Fires whenever the SDK receives a new event. + *

    + * This is only fired for live events received via /sync - it is not fired for + * events received over context, search, or pagination APIs. + * + * @event module:client~MatrixClient#"event" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @example + * matrixClient.on("event", function(event){ + * var sender = event.getSender(); + * }); + */ + +/** + * Fires whenever the SDK receives a new to-device event. + * @event module:client~MatrixClient#"toDeviceEvent" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @example + * matrixClient.on("toDeviceEvent", function(event){ + * var sender = event.getSender(); + * }); + */ + +/** + * Fires whenever the SDK's syncing state is updated. The state can be one of: + *

      + * + *
    • PREPARED: The client has synced with the server at least once and is + * ready for methods to be called on it. This will be immediately followed by + * a state of SYNCING. This is the equivalent of "syncComplete" in the + * previous API.
    • + * + *
    • CATCHUP: The client has detected the connection to the server might be + * available again and will now try to do a sync again. As this sync might take + * a long time (depending how long ago was last synced, and general server + * performance) the client is put in this mode so the UI can reflect trying + * to catch up with the server after losing connection.
    • + * + *
    • SYNCING : The client is currently polling for new events from the server. + * This will be called after processing latest events from a sync.
    • + * + *
    • ERROR : The client has had a problem syncing with the server. If this is + * called before PREPARED then there was a problem performing the initial + * sync. If this is called after PREPARED then there was a problem polling + * the server for updates. This may be called multiple times even if the state is + * already ERROR. This is the equivalent of "syncError" in the previous + * API.
    • + * + *
    • RECONNECTING: The sync connection has dropped, but not (yet) in a way that + * should be considered erroneous. + *
    • + * + *
    • STOPPED: The client has stopped syncing with server due to stopClient + * being called. + *
    • + *
    + * State transition diagram: + *
    + *                                          +---->STOPPED
    + *                                          |
    + *              +----->PREPARED -------> SYNCING <--+
    + *              |                        ^  |  ^    |
    + *              |      CATCHUP ----------+  |  |    |
    + *              |        ^                  V  |    |
    + *   null ------+        |  +------- RECONNECTING   |
    + *              |        V  V                       |
    + *              +------->ERROR ---------------------+
    + *
    + * NB: 'null' will never be emitted by this event.
    + *
    + * 
    + * Transitions: + *
      + * + *
    • null -> PREPARED : Occurs when the initial sync is completed + * first time. This involves setting up filters and obtaining push rules. + * + *
    • null -> ERROR : Occurs when the initial sync failed first time. + * + *
    • ERROR -> PREPARED : Occurs when the initial sync succeeds + * after previously failing. + * + *
    • PREPARED -> SYNCING : Occurs immediately after transitioning + * to PREPARED. Starts listening for live updates rather than catching up. + * + *
    • SYNCING -> RECONNECTING : Occurs when the live update fails. + * + *
    • RECONNECTING -> RECONNECTING : Can occur if the update calls + * continue to fail, but the keepalive calls (to /versions) succeed. + * + *
    • RECONNECTING -> ERROR : Occurs when the keepalive call also fails + * + *
    • ERROR -> SYNCING : Occurs when the client has performed a + * live update after having previously failed. + * + *
    • ERROR -> ERROR : Occurs when the client has failed to keepalive + * for a second time or more.
    • + * + *
    • SYNCING -> SYNCING : Occurs when the client has performed a live + * update. This is called after processing.
    • + * + *
    • * -> STOPPED : Occurs once the client has stopped syncing or + * trying to sync after stopClient has been called.
    • + *
    + * + * @event module:client~MatrixClient#"sync" + * + * @param {string} state An enum representing the syncing state. One of "PREPARED", + * "SYNCING", "ERROR", "STOPPED". + * + * @param {?string} prevState An enum representing the previous syncing state. + * One of "PREPARED", "SYNCING", "ERROR", "STOPPED" or null. + * + * @param {?Object} data Data about this transition. + * + * @param {MatrixError} data.error The matrix error if state=ERROR. + * + * @param {String} data.oldSyncToken The 'since' token passed to /sync. + * null for the first successful sync since this client was + * started. Only present if state=PREPARED or + * state=SYNCING. + * + * @param {String} data.nextSyncToken The 'next_batch' result from /sync, which + * will become the 'since' token for the next call to /sync. Only present if + * state=PREPARED or state=SYNCING. + * + * @param {boolean} data.catchingUp True if we are working our way through a + * backlog of events after connecting. Only present if state=SYNCING. + * + * @example + * matrixClient.on("sync", function(state, prevState, data) { + * switch (state) { + * case "ERROR": + * // update UI to say "Connection Lost" + * break; + * case "SYNCING": + * // update UI to remove any "Connection Lost" message + * break; + * case "PREPARED": + * // the client instance is ready to be queried. + * var rooms = matrixClient.getRooms(); + * break; + * } + * }); + */ + + /** + * Fires whenever the sdk learns about a new group. This event + * is experimental and may change. + * @event module:client~MatrixClient#"Group" + * @param {Group} group The newly created, fully populated group. + * @example + * matrixClient.on("Group", function(group){ + * var groupId = group.groupId; + * }); + */ + + /** + * Fires whenever a new Room is added. This will fire when you are invited to a + * room, as well as when you join a room. This event is experimental and + * may change. + * @event module:client~MatrixClient#"Room" + * @param {Room} room The newly created, fully populated room. + * @example + * matrixClient.on("Room", function(room){ + * var roomId = room.roomId; + * }); + */ + + /** + * Fires whenever a Room is removed. This will fire when you forget a room. + * This event is experimental and may change. + * @event module:client~MatrixClient#"deleteRoom" + * @param {string} roomId The deleted room ID. + * @example + * matrixClient.on("deleteRoom", function(roomId){ + * // update UI from getRooms() + * }); + */ + +/** + * Fires whenever an incoming call arrives. + * @event module:client~MatrixClient#"Call.incoming" + * @param {module:webrtc/call~MatrixCall} call The incoming call. + * @example + * matrixClient.on("Call.incoming", function(call){ + * call.answer(); // auto-answer + * }); + */ + +/** + * Fires whenever the login session the JS SDK is using is no + * longer valid and the user must log in again. + * NB. This only fires when action is required from the user, not + * when then login session can be renewed by using a refresh token. + * @event module:client~MatrixClient#"Session.logged_out" + * @example + * matrixClient.on("Session.logged_out", function(errorObj){ + * // show the login screen + * }); + */ + +/** + * Fires when the JS SDK receives a M_CONSENT_NOT_GIVEN error in response + * to a HTTP request. + * @event module:client~MatrixClient#"no_consent" + * @example + * matrixClient.on("no_consent", function(message, contentUri) { + * console.info(message + ' Go to ' + contentUri); + * }); + */ + +/** + * Fires when a device is marked as verified/unverified/blocked/unblocked by + * {@link module:client~MatrixClient#setDeviceVerified|MatrixClient.setDeviceVerified} or + * {@link module:client~MatrixClient#setDeviceBlocked|MatrixClient.setDeviceBlocked}. + * + * @event module:client~MatrixClient#"deviceVerificationChanged" + * @param {string} userId the owner of the verified device + * @param {string} deviceId the id of the verified device + * @param {module:crypto/deviceinfo} deviceInfo updated device information + */ + +/** + * Fires whenever new user-scoped account_data is added. + * @event module:client~MatrixClient#"accountData" + * @param {MatrixEvent} event The event describing the account_data just added + * @example + * matrixClient.on("accountData", function(event){ + * myAccountData[event.type] = event.content; + * }); + */ + +/** + * Fires whenever the status of e2e key backup changes, as returned by getKeyBackupEnabled() + * @event module:client~MatrixClient#"crypto.keyBackupStatus" + * @param {bool} enabled true if key backup has been enabled, otherwise false + * @example + * matrixClient.on("crypto.keyBackupStatus", function(enabled){ + * if (enabled) { + * [...] + * } + * }); + */ + +/** + * Fires when we want to suggest to the user that they restore their megolm keys + * from backup or by cross-signing the device. + * + * @event module:client~MatrixClient#"crypto.suggestKeyRestore" + */ + +/** + * Fires when a key verification is requested. + * @event module:client~MatrixClient#"crypto.verification.request" + * @param {object} data + * @param {MatrixEvent} data.event the original verification request message + * @param {Array} data.methods the verification methods that can be used + * @param {Function} data.beginKeyVerification a function to call if a key + * verification should be performed. The function takes one argument: the + * name of the key verification method (taken from data.methods) to use. + * @param {Function} data.cancel a function to call if the key verification is + * rejected. + */ + +/** + * Fires when a key verification is requested with an unknown method. + * @event module:client~MatrixClient#"crypto.verification.request.unknown" + * @param {string} userId the user ID who requested the key verification + * @param {Function} cancel a function that will send a cancellation message to + * reject the key verification. + */ + +/** + * Fires when a key verification started message is received. + * @event module:client~MatrixClient#"crypto.verification.start" + * @param {module:crypto/verification/Base} verifier a verifier object to + * perform the key verification + */ + +// EventEmitter JSDocs + +/** + * The {@link https://nodejs.org/api/events.html|EventEmitter} class. + * @external EventEmitter + * @see {@link https://nodejs.org/api/events.html} + */ + +/** + * Adds a listener to the end of the listeners array for the specified event. + * No checks are made to see if the listener has already been added. Multiple + * calls passing the same combination of event and listener will result in the + * listener being added multiple times. + * @function external:EventEmitter#on + * @param {string} event The event to listen for. + * @param {Function} listener The function to invoke. + * @return {EventEmitter} for call chaining. + */ + +/** + * Alias for {@link external:EventEmitter#on}. + * @function external:EventEmitter#addListener + * @param {string} event The event to listen for. + * @param {Function} listener The function to invoke. + * @return {EventEmitter} for call chaining. + */ + +/** + * Adds a one time listener for the event. This listener is invoked only + * the next time the event is fired, after which it is removed. + * @function external:EventEmitter#once + * @param {string} event The event to listen for. + * @param {Function} listener The function to invoke. + * @return {EventEmitter} for call chaining. + */ + +/** + * Remove a listener from the listener array for the specified event. + * Caution: changes array indices in the listener array behind the + * listener. + * @function external:EventEmitter#removeListener + * @param {string} event The event to listen for. + * @param {Function} listener The function to invoke. + * @return {EventEmitter} for call chaining. + */ + +/** + * Removes all listeners, or those of the specified event. It's not a good idea + * to remove listeners that were added elsewhere in the code, especially when + * it's on an emitter that you didn't create (e.g. sockets or file streams). + * @function external:EventEmitter#removeAllListeners + * @param {string} event Optional. The event to remove listeners for. + * @return {EventEmitter} for call chaining. + */ + +/** + * Execute each of the listeners in order with the supplied arguments. + * @function external:EventEmitter#emit + * @param {string} event The event to emit. + * @param {Function} listener The function to invoke. + * @return {boolean} true if event had listeners, false otherwise. + */ + +/** + * By default EventEmitters will print a warning if more than 10 listeners are + * added for a particular event. This is a useful default which helps finding + * memory leaks. Obviously not all Emitters should be limited to 10. This + * function allows that to be increased. Set to zero for unlimited. + * @function external:EventEmitter#setMaxListeners + * @param {Number} n The max number of listeners. + * @return {EventEmitter} for call chaining. + */ + +// MatrixClient Callback JSDocs + +/** + * The standard MatrixClient callback interface. Functions which accept this + * will specify 2 return arguments. These arguments map to the 2 parameters + * specified in this callback. + * @callback module:client.callback + * @param {Object} err The error value, the "rejected" value or null. + * @param {Object} data The data returned, the "resolved" value. + */ + + /** + * {@link https://github.com/kriskowal/q|A promise implementation (Q)}. Functions + * which return this will specify 2 return arguments. These arguments map to the + * "onFulfilled" and "onRejected" values of the Promise. + * @typedef {Object} Promise + * @static + * @property {Function} then promise.then(onFulfilled, onRejected, onProgress) + * @property {Function} catch promise.catch(onRejected) + * @property {Function} finally promise.finally(callback) + * @property {Function} done promise.done(onFulfilled, onRejected, onProgress) + */ diff --git a/matrix-js-sdk/src/content-helpers.js b/matrix-js-sdk/src/content-helpers.js new file mode 100644 index 000000000..a1afe1f71 --- /dev/null +++ b/matrix-js-sdk/src/content-helpers.js @@ -0,0 +1,100 @@ +/* +Copyright 2018 New Vector 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. +*/ +"use strict"; + +/** @module ContentHelpers */ +module.exports = { + /** + * Generates the content for a HTML Message event + * @param {string} body the plaintext body of the message + * @param {string} htmlBody the HTML representation of the message + * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} + */ + makeHtmlMessage: function(body, htmlBody) { + return { + msgtype: "m.text", + format: "org.matrix.custom.html", + body: body, + formatted_body: htmlBody, + }; + }, + + /** + * Generates the content for a HTML Notice event + * @param {string} body the plaintext body of the notice + * @param {string} htmlBody the HTML representation of the notice + * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} + */ + makeHtmlNotice: function(body, htmlBody) { + return { + msgtype: "m.notice", + format: "org.matrix.custom.html", + body: body, + formatted_body: htmlBody, + }; + }, + + /** + * Generates the content for a HTML Emote event + * @param {string} body the plaintext body of the emote + * @param {string} htmlBody the HTML representation of the emote + * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} + */ + makeHtmlEmote: function(body, htmlBody) { + return { + msgtype: "m.emote", + format: "org.matrix.custom.html", + body: body, + formatted_body: htmlBody, + }; + }, + + /** + * Generates the content for a Plaintext Message event + * @param {string} body the plaintext body of the emote + * @returns {{msgtype: string, body: string}} + */ + makeTextMessage: function(body) { + return { + msgtype: "m.text", + body: body, + }; + }, + + /** + * Generates the content for a Plaintext Notice event + * @param {string} body the plaintext body of the notice + * @returns {{msgtype: string, body: string}} + */ + makeNotice: function(body) { + return { + msgtype: "m.notice", + body: body, + }; + }, + + /** + * Generates the content for a Plaintext Emote event + * @param {string} body the plaintext body of the emote + * @returns {{msgtype: string, body: string}} + */ + makeEmoteMessage: function(body) { + return { + msgtype: "m.emote", + body: body, + }; + }, +}; diff --git a/matrix-js-sdk/src/content-repo.js b/matrix-js-sdk/src/content-repo.js new file mode 100644 index 000000000..9c0a3306b --- /dev/null +++ b/matrix-js-sdk/src/content-repo.js @@ -0,0 +1,110 @@ +/* +Copyright 2015, 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. +*/ +/** + * @module content-repo + */ +const utils = require("./utils"); + +/** Content Repo utility functions */ +module.exports = { + /** + * Get the HTTP URL for an MXC URI. + * @param {string} baseUrl The base homeserver url which has a content repo. + * @param {string} mxc The mxc:// URI. + * @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} allowDirectLinks If true, return any non-mxc URLs + * directly. Fetching such URLs will leak information about the user to + * anyone they share a room with. If false, will return the emptry string + * for such URLs. + * @return {string} The complete URL to the content. + */ + getHttpUriForMxc: function(baseUrl, mxc, width, height, + resizeMethod, allowDirectLinks) { + if (typeof mxc !== "string" || !mxc) { + return ''; + } + if (mxc.indexOf("mxc://") !== 0) { + if (allowDirectLinks) { + return mxc; + } else { + return ''; + } + } + let serverAndMediaId = mxc.slice(6); // strips mxc:// + let prefix = "/_matrix/media/r0/download/"; + const params = {}; + + if (width) { + params.width = Math.round(width); + } + if (height) { + params.height = Math.round(height); + } + if (resizeMethod) { + params.method = resizeMethod; + } + if (utils.keys(params).length > 0) { + // these are thumbnailing params so they probably want the + // thumbnailing API... + prefix = "/_matrix/media/r0/thumbnail/"; + } + + const fragmentOffset = serverAndMediaId.indexOf("#"); + let fragment = ""; + if (fragmentOffset >= 0) { + fragment = serverAndMediaId.substr(fragmentOffset); + serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset); + } + return baseUrl + prefix + serverAndMediaId + + (utils.keys(params).length === 0 ? "" : + ("?" + utils.encodeParams(params))) + fragment; + }, + + /** + * Get an identicon URL from an arbitrary string. + * @param {string} baseUrl The base homeserver url which has a content repo. + * @param {string} identiconString The string to create an identicon for. + * @param {Number} width The desired width of the image in pixels. Default: 96. + * @param {Number} height The desired height of the image in pixels. Default: 96. + * @return {string} The complete URL to the identicon. + * @deprecated This is no longer in the specification. + */ + getIdenticonUri: function(baseUrl, identiconString, width, height) { + if (!identiconString) { + return null; + } + if (!width) { + width = 96; + } + if (!height) { + height = 96; + } + const params = { + width: width, + height: height, + }; + + const path = utils.encodeUri("/_matrix/media/unstable/identicon/$ident", { + $ident: identiconString, + }); + return baseUrl + path + + (utils.keys(params).length === 0 ? "" : + ("?" + utils.encodeParams(params))); + }, +}; diff --git a/matrix-js-sdk/src/crypto/DeviceList.js b/matrix-js-sdk/src/crypto/DeviceList.js new file mode 100644 index 000000000..5e50189e4 --- /dev/null +++ b/matrix-js-sdk/src/crypto/DeviceList.js @@ -0,0 +1,890 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018, 2019 New Vector 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. +*/ +"use strict"; + +/** + * @module crypto/DeviceList + * + * Manages the list of other users' devices + */ + +import Promise from 'bluebird'; + +import logger from '../logger'; +import DeviceInfo from './deviceinfo'; +import olmlib from './olmlib'; +import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; + + +/* State transition diagram for DeviceList._deviceTrackingStatus + * + * | + * stopTrackingDeviceList V + * +---------------------> NOT_TRACKED + * | | + * +<--------------------+ | startTrackingDeviceList + * | | V + * | +-------------> PENDING_DOWNLOAD <--------------------+-+ + * | | ^ | | | + * | | restart download | | start download | | invalidateUserDeviceList + * | | client failed | | | | + * | | | V | | + * | +------------ DOWNLOAD_IN_PROGRESS -------------------+ | + * | | | | + * +<-------------------+ | download successful | + * ^ V | + * +----------------------- UP_TO_DATE ------------------------+ + */ + + +// 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; + +/** + * @alias module:crypto/DeviceList + */ +export default class DeviceList { + constructor(baseApis, cryptoStore, olmDevice) { + this._cryptoStore = cryptoStore; + + // userId -> { + // deviceId -> { + // [device info] + // } + // } + this._devices = {}; + + // 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 = {}; + + // 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; + } + + /** + * Load the device tracking state from storage + */ + async load() { + await this._cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { + this._cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { + this._devices = deviceData ? deviceData.devices : {}, + 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]; + for (const device of Object.keys(userDevices)) { + const idKey = userDevices[device].keys['curve25519:'+device]; + if (idKey !== undefined) { + this._userByIdentityKey[idKey] = user; + } + } + } + }); + }, + ); + + 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; + } + } + } + + stop() { + if (this._saveTimer !== null) { + clearTimeout(this._saveTimer); + } + } + + /** + * Save the device tracking state to storage, if any changes are + * pending other than updating the sync token + * + * 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. + * 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. + */ + async saveIfDirty(delay) { + 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) { + // 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; + // (but keep the save promise since whatever called save before + // will still want to know when the save is done) + } + + let savePromise = this._savePromise; + if (savePromise === null) { + savePromise = new Promise((resolve, reject) => { + this._resolveSavePromise = resolve; + }); + this._savePromise = savePromise; + } + + if (this._saveTimer === null) { + const resolveSavePromise = this._resolveSavePromise; + this._savePromiseTime = targetTime; + this._saveTimer = setTimeout(() => { + logger.log('Saving device tracking data at token ' + 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. Likewise for the dirty flag. + this._savePromiseTime = null; + this._saveTimer = null; + this._savePromise = null; + this._resolveSavePromise = null; + + this._dirty = false; + this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { + this._cryptoStore.storeEndToEndDeviceData({ + devices: this._devices, + trackingStatus: this._deviceTrackingStatus, + syncToken: this._syncToken, + }, txn); + }, + ).then(() => { + resolveSavePromise(); + }); + }, delay); + } + return savePromise; + } + + /** + * Gets the sync token last set with setSyncToken + * + * @return {string} The sync token + */ + getSyncToken() { + return this._syncToken; + } + + /** + * Sets the sync token that the app will pass as the 'since' to the /sync + * endpoint next time it syncs. + * The sync token must always be set after any changes made as a result of + * data in that sync since setting the sync token to a newer one will mean + * those changed will not be synced from the server if a new client starts + * up with that data. + * + * @param {string} st The sync token + */ + setSyncToken(st) { + this._syncToken = st; + } + + /** + * Ensures up to date keys for a list of users are stored in the session store, + * 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. + * + * @return {Promise} A promise which resolves to a map userId->deviceId->{@link + * module:crypto/deviceinfo|DeviceInfo}. + */ + downloadKeys(userIds, forceDownload) { + const usersToDownload = []; + const promises = []; + + userIds.forEach((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) { + usersToDownload.push(u); + } + }); + + if (usersToDownload.length != 0) { + logger.log("downloadKeys: downloading for", usersToDownload); + const downloadPromise = this._doKeyDownload(usersToDownload); + promises.push(downloadPromise); + } + + if (promises.length === 0) { + logger.log("downloadKeys: already have all necessary keys"); + } + + return Promise.all(promises).then(() => { + return this._getDevicesFromStore(userIds); + }); + } + + /** + * Get the stored device keys for a list of user ids + * + * @param {string[]} userIds the list of users to list keys for. + * + * @return {Object} userId->deviceId->{@link module:crypto/deviceinfo|DeviceInfo}. + */ + _getDevicesFromStore(userIds) { + const stored = {}; + const self = this; + userIds.map(function(u) { + stored[u] = {}; + const devices = self.getStoredDevicesForUser(u) || []; + devices.map(function(dev) { + stored[u][dev.deviceId] = dev; + }); + }); + return stored; + } + + /** + * 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. + */ + getStoredDevicesForUser(userId) { + const devs = this._devices[userId]; + if (!devs) { + return null; + } + const res = []; + for (const deviceId in devs) { + if (devs.hasOwnProperty(deviceId)) { + res.push(DeviceInfo.fromStorage(devs[deviceId], deviceId)); + } + } + return res; + } + + /** + * Get the stored device data for a user, in raw object form + * + * @param {string} userId the user to get data for + * + * @return {Object} deviceId->{object} devices, or undefined if + * there is no data for this user. + */ + getRawStoredDevicesForUser(userId) { + return this._devices[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 + */ + getStoredDevice(userId, deviceId) { + const devs = this._devices[userId]; + if (!devs || !devs[deviceId]) { + return undefined; + } + return DeviceInfo.fromStorage(devs[deviceId], deviceId); + } + + /** + * Find a device by curve25519 identity key + * + * @param {string} algorithm encryption algorithm + * @param {string} senderKey curve25519 key to match + * + * @return {module:crypto/deviceinfo?} + */ + getDeviceByIdentityKey(algorithm, senderKey) { + const userId = this._userByIdentityKey[senderKey]; + if (!userId) { + return null; + } + + if ( + algorithm !== olmlib.OLM_ALGORITHM && + algorithm !== olmlib.MEGOLM_ALGORITHM + ) { + // we only deal in olm keys + return null; + } + + const devices = this._devices[userId]; + if (!devices) { + return null; + } + + for (const deviceId in devices) { + if (!devices.hasOwnProperty(deviceId)) { + continue; + } + + const device = devices[deviceId]; + for (const keyId in device.keys) { + if (!device.keys.hasOwnProperty(keyId)) { + continue; + } + if (keyId.indexOf("curve25519:") !== 0) { + continue; + } + const deviceKey = device.keys[keyId]; + if (deviceKey == senderKey) { + return DeviceInfo.fromStorage(device, deviceId); + } + } + } + + // doesn't match a known device + return null; + } + + /** + * 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 + */ + storeDevicesForUser(u, devs) { + // 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; + } + this._dirty = true; + } + + /** + * flag the given user for device-list tracking, if they are not already. + * + * This will mean that a subsequent call to refreshOutdatedDeviceLists() + * will download the device list for the user, and that subsequent calls to + * invalidateUserDeviceList will trigger more updates. + * + * @param {String} userId + */ + startTrackingDeviceList(userId) { + // 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 + // the broken userId is always 'invalid' and always included in any + // refresh request). + // By checking it is at least a string, we can eliminate a class of + // silly errors. + if (typeof userId !== 'string') { + throw new Error('userId must be a string; was '+userId); + } + if (!this._deviceTrackingStatus[userId]) { + logger.log('Now tracking device list for ' + userId); + this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD; + // 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; + } + } + + /** + * Mark the given user as no longer being tracked for device-list updates. + * + * This won't affect any in-progress downloads, which will still go on to + * complete; it will just mean that we don't think that we have an up-to-date + * list for future calls to downloadKeys. + * + * @param {String} userId + */ + stopTrackingDeviceList(userId) { + if (this._deviceTrackingStatus[userId]) { + logger.log('No longer tracking device list for ' + userId); + this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED; + + // 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; + } + } + + /** + * Set all users we're currently tracking to untracked + * + * 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; + } + this._dirty = true; + } + + /** + * Mark the cached device list for the given user outdated. + * + * If we are not tracking this user's devices, we'll do nothing. Otherwise + * we flag the user as needing an update. + * + * This doesn't actually set off an update, so that several users can be + * batched together. Call refreshOutdatedDeviceLists() for that. + * + * @param {String} userId + */ + invalidateUserDeviceList(userId) { + if (this._deviceTrackingStatus[userId]) { + logger.log("Marking device list outdated for", userId); + this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD; + + // 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; + } + } + + /** + * If we have users who have outdated device lists, start key downloads for them + * + * @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() { + this.saveIfDirty(); + + const usersToDownload = []; + for (const userId of Object.keys(this._deviceTrackingStatus)) { + const stat = this._deviceTrackingStatus[userId]; + if (stat == TRACKING_STATUS_PENDING_DOWNLOAD) { + usersToDownload.push(userId); + } + } + + return this._doKeyDownload(usersToDownload); + } + + /** + * Set the stored device data for a user, in raw object form + * Used only by internal class DeviceListUpdateSerialiser + * + * @param {string} userId the user to get data for + * + * @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])) { + const identityKey = dev.keys['curve25519:'+deviceId]; + + delete this._userByIdentityKey[identityKey]; + } + } + + this._devices[userId] = devices; + + // add new devices into _userByIdentityKey + for (const [deviceId, dev] of Object.entries(devices)) { + const identityKey = dev.keys['curve25519:'+deviceId]; + + this._userByIdentityKey[identityKey] = userId; + } + } + + /** + * Fire off download update requests for the given users, and update the + * device list tracking status for them, and the + * _keyDownloadsInProgressByUser map for them. + * + * @param {String[]} users list of userIds + * + * @return {module:client.Promise} resolves when all the users listed have + * been updated. rejects if there was a problem updating any of the + * users. + */ + _doKeyDownload(users) { + if (users.length === 0) { + // nothing to do + return Promise.resolve(); + } + + const prom = this._serialiser.updateDevicesForUsers( + users, this._syncToken, + ).then(() => { + finished(true); + }, (e) => { + logger.error( + 'Error downloading keys for ' + users + ":", e, + ); + finished(false); + throw e; + }); + + 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; + } + }); + + const finished = (success) => { + users.forEach((u) => { + 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'); + return; + } + delete this._keyDownloadsInProgressByUser[u]; + const stat = this._deviceTrackingStatus[u]; + if (stat == TRACKING_STATUS_DOWNLOAD_IN_PROGRESS) { + 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; + logger.log("Device list for", u, "now up to date"); + } else { + this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD; + } + } + }); + this.saveIfDirty(); + }; + + return prom; + } +} + +/** + * Serialises updates to device lists + * + * Ensures that results from /keys/query are not overwritten if a second call + * completes *before* an earlier one. + * + * It currently does this by ensuring only one call to /keys/query happens at a + * time (and queuing other requests up). + */ +class DeviceListUpdateSerialiser { + /* + * @param {object} baseApis Base API object + * @param {object} olmDevice The Olm Device + * @param {object} deviceList The device list object + */ + 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 + } + + /** + * Make a key query request for the given users + * + * @param {String[]} users list of user ids + * + * @param {String} syncToken sync token to pass in the query request, to + * help the HS give the most recent results + * + * @return {module:client.Promise} resolves when all the users listed have + * been updated. rejects if there was a problem updating any of the + * users. + */ + updateDevicesForUsers(users, syncToken) { + users.forEach((u) => { + this._keyDownloadsQueuedByUser[u] = true; + }); + + if (!this._queuedQueryDeferred) { + this._queuedQueryDeferred = Promise.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; + + if (this._downloadInProgress) { + // just queue up these users + logger.log('Queued key download for', users); + return this._queuedQueryDeferred.promise; + } + + // start a new download. + return this._doQueuedQueries(); + } + + _doQueuedQueries() { + if (this._downloadInProgress) { + throw new Error( + "DeviceListUpdateSerialiser._doQueuedQueries called with request active", + ); + } + + 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; + + const opts = {}; + if (this._syncToken) { + opts.token = this._syncToken; + } + + this._baseApis.downloadKeysForUsers( + downloadUsers, opts, + ).then((res) => { + const dk = res.device_keys || {}; + + // do each user in a separate promise, to avoid wedging the CPU + // (https://github.com/vector-im/riot-web/issues/3158) + // + // of course we ought to do this in a web worker or similar, but + // this serves as an easy solution for now. + let prom = Promise.resolve(); + for (const userId of downloadUsers) { + prom = prom.delay(5).then(() => { + return this._processQueryResponseForUser(userId, dk[userId]); + }); + } + + return prom; + }).done(() => { + logger.log('Completed key download for ' + downloadUsers); + + this._downloadInProgress = false; + deferred.resolve(); + + // if we have queued users, fire off another request. + if (this._queuedQueryDeferred) { + this._doQueuedQueries(); + } + }, (e) => { + logger.warn('Error downloading keys for ' + downloadUsers + ':', e); + this._downloadInProgress = false; + deferred.reject(e); + }); + + return deferred.promise; + } + + async _processQueryResponseForUser(userId, response) { + logger.log('got keys for ' + userId + ':', response); + + // map from deviceid -> deviceinfo for this user + const userStore = {}; + const devs = this._deviceList.getRawStoredDevicesForUser(userId); + if (devs) { + Object.keys(devs).forEach((deviceId) => { + const d = DeviceInfo.fromStorage(devs[deviceId], deviceId); + userStore[deviceId] = d; + }); + } + + await _updateStoredDeviceKeysForUser( + this._olmDevice, userId, userStore, response || {}, + ); + + // put the updates into thr object that will be returned as our results + const storage = {}; + Object.keys(userStore).forEach((deviceId) => { + storage[deviceId] = userStore[deviceId].toStorage(); + }); + + this._deviceList._setRawStoredDevicesForUser(userId, storage); + } +} + + +async function _updateStoredDeviceKeysForUser(_olmDevice, userId, userStore, + userResult) { + let updated = false; + + // remove any devices in the store which aren't in the response + for (const deviceId in userStore) { + if (!userStore.hasOwnProperty(deviceId)) { + continue; + } + + if (!(deviceId in userResult)) { + logger.log("Device " + userId + ":" + deviceId + + " has been removed"); + delete userStore[deviceId]; + updated = true; + } + } + + for (const deviceId in userResult) { + if (!userResult.hasOwnProperty(deviceId)) { + continue; + } + + const deviceResult = userResult[deviceId]; + + // check that the user_id and device_id in the response object are + // correct + if (deviceResult.user_id !== userId) { + logger.warn("Mismatched user_id " + deviceResult.user_id + + " in keys from " + userId + ":" + deviceId); + continue; + } + if (deviceResult.device_id !== deviceId) { + logger.warn("Mismatched device_id " + deviceResult.device_id + + " in keys from " + userId + ":" + deviceId); + continue; + } + + if (await _storeDeviceKeys(_olmDevice, userStore, deviceResult)) { + updated = true; + } + } + + return updated; +} + +/* + * Process a device in a /query response, and add it to the userStore + * + * returns (a promise for) true if a change was made, else false + */ +async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) { + if (!deviceResult.keys) { + // no keys? + return false; + } + + const deviceId = deviceResult.device_id; + const userId = deviceResult.user_id; + + const signKeyId = "ed25519:" + deviceId; + const signKey = deviceResult.keys[signKeyId]; + if (!signKey) { + logger.warn("Device " + userId + ":" + deviceId + + " has no ed25519 key"); + return false; + } + + const unsigned = deviceResult.unsigned || {}; + + try { + await olmlib.verifySignature(_olmDevice, deviceResult, userId, deviceId, signKey); + } catch (e) { + logger.warn("Unable to verify signature on device " + + userId + ":" + deviceId + ":" + e); + return false; + } + + // DeviceInfo + let deviceStore; + + if (deviceId in userStore) { + // already have this device. + deviceStore = userStore[deviceId]; + + if (deviceStore.getFingerprint() != signKey) { + // this should only happen if the list has been MITMed; we are + // best off sticking with the original keys. + // + // Should we warn the user about it somehow? + logger.warn("Ed25519 key for device " + userId + ":" + + deviceId + " has changed"); + return false; + } + } else { + userStore[deviceId] = deviceStore = new DeviceInfo(deviceId); + } + + deviceStore.keys = deviceResult.keys || {}; + deviceStore.algorithms = deviceResult.algorithms || []; + deviceStore.unsigned = unsigned; + return true; +} diff --git a/matrix-js-sdk/src/crypto/OlmDevice.js b/matrix-js-sdk/src/crypto/OlmDevice.js new file mode 100644 index 000000000..d47f19baa --- /dev/null +++ b/matrix-js-sdk/src/crypto/OlmDevice.js @@ -0,0 +1,1130 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017, 2019 New Vector 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. +*/ + +import logger from '../logger'; +import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; + +// The maximum size of an event is 65K, and we base64 the content, so this is a +// reasonable approximation to the biggest plaintext we can encrypt. +const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4; + +function checkPayloadLength(payloadString) { + if (payloadString === undefined) { + throw new Error("payloadString undefined"); + } + + if (payloadString.length > MAX_PLAINTEXT_LENGTH) { + // might as well fail early here rather than letting the olm library throw + // a cryptic memory allocation error. + // + // Note that even if we manage to do the encryption, the message send may fail, + // because by the time we've wrapped the ciphertext in the event object, it may + // exceed 65K. But at least we won't just fail with "abort()" in that case. + throw new Error("Message too long (" + payloadString.length + " bytes). " + + "The maximum for an encrypted message is " + + MAX_PLAINTEXT_LENGTH + " bytes."); + } +} + + +/** + * The type of object we use for importing and exporting megolm session data. + * + * @typedef {Object} module:crypto/OlmDevice.MegolmSessionData + * @property {String} sender_key Sender's Curve25519 device key + * @property {String[]} forwarding_curve25519_key_chain Devices which forwarded + * this session to us (normally empty). + * @property {Object} sender_claimed_keys Other keys the sender claims. + * @property {String} room_id Room this session is used in + * @property {String} session_id Unique id for the session + * @property {String} session_key Base64'ed key data + */ + + +/** + * Manages the olm cryptography functions. Each OlmDevice has a single + * OlmAccount and a number of OlmSessions. + * + * Accounts and sessions are kept pickled in the cryptoStore. + * + * @constructor + * @alias module:crypto/OlmDevice + * + * @param {Object} cryptoStore A store for crypto data + * + * @property {string} deviceCurve25519Key Curve25519 key for the account + * @property {string} deviceEd25519Key Ed25519 key for the account + */ +function OlmDevice(cryptoStore) { + this._cryptoStore = cryptoStore; + this._pickleKey = "DEFAULT_KEY"; + + // don't know these until we load the account from storage in init() + this.deviceCurve25519Key = null; + this.deviceEd25519Key = null; + this._maxOneTimeKeys = null; + + // we don't bother stashing outboundgroupsessions in the cryptoStore - + // instead we keep them here. + this._outboundGroupSessionStore = {}; + + // Store a set of decrypted message indexes for each group session. + // This partially mitigates a replay attack where a MITM resends a group + // message into the room. + // + // When we decrypt a message and the message index matches a previously + // decrypted message, one possible cause of that is that we are decrypting + // the same event, and may not indicate an actual replay attack. For + // example, this could happen if we receive events, forget about them, and + // then re-fetch them when we backfill. So we store the event ID and + // timestamp corresponding to each message index when we first decrypt it, + // and compare these against the event ID and timestamp every time we use + // that same index. If they match, then we're probably decrypting the same + // event and we don't consider it a replay attack. + // + // Keys are strings of form "||" + // Values are objects of the form "{id: , timestamp: }" + this._inboundGroupSessionMessageIndexes = {}; + + // Keep track of sessions that we're starting, so that we don't start + // multiple sessions for the same device at the same time. + this._sessionsInProgress = {}; +} + +/** + * Initialise the OlmAccount. This must be called before any other operations + * on the OlmDevice. + * + * Attempts to load the OlmAccount from the crypto store, or creates one if none is + * found. + * + * Reads the device keys from the OlmAccount object. + */ +OlmDevice.prototype.init = async function() { + let e2eKeys; + const account = new global.Olm.Account(); + try { + await _initialiseAccount(this._cryptoStore, this._pickleKey, account); + e2eKeys = JSON.parse(account.identity_keys()); + + this._maxOneTimeKeys = account.max_number_of_one_time_keys(); + } finally { + account.free(); + } + + this.deviceCurve25519Key = e2eKeys.curve25519; + this.deviceEd25519Key = e2eKeys.ed25519; +}; + +async function _initialiseAccount(cryptoStore, pickleKey, account) { + await cryptoStore.doTxn('readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { + cryptoStore.getAccount(txn, (pickledAccount) => { + if (pickledAccount !== null) { + account.unpickle(pickleKey, pickledAccount); + } else { + account.create(); + pickledAccount = account.pickle(pickleKey); + cryptoStore.storeAccount(txn, pickledAccount); + } + }); + }); +} + +/** + * @return {array} The version of Olm. + */ +OlmDevice.getOlmVersion = function() { + return global.Olm.get_library_version(); +}; + +/** + * extract our OlmAccount from the crypto store and call the given function + * with the account object + * The `account` object is useable only within the callback passed to this + * function and will be freed as soon the callback returns. It is *not* + * useable for the rest of the lifetime of the transaction. + * This function requires a live transaction object from cryptoStore.doTxn() + * and therefore may only be called in a doTxn() callback. + * + * @param {*} txn Opaque transaction object from cryptoStore.doTxn() + * @param {function} func + * @private + */ +OlmDevice.prototype._getAccount = function(txn, func) { + this._cryptoStore.getAccount(txn, (pickledAccount) => { + const account = new global.Olm.Account(); + try { + account.unpickle(this._pickleKey, pickledAccount); + func(account); + } finally { + account.free(); + } + }); +}; + +/* + * Saves an account to the crypto store. + * This function requires a live transaction object from cryptoStore.doTxn() + * and therefore may only be called in a doTxn() callback. + * + * @param {*} txn Opaque transaction object from cryptoStore.doTxn() + * @param {object} Olm.Account object + * @private + */ +OlmDevice.prototype._storeAccount = function(txn, account) { + this._cryptoStore.storeAccount(txn, account.pickle(this._pickleKey)); +}; + +/** + * extract an OlmSession from the session store and call the given function + * The session is useable only within the callback passed to this + * function and will be freed as soon the callback returns. It is *not* + * useable for the rest of the lifetime of the transaction. + * + * @param {string} deviceKey + * @param {string} sessionId + * @param {*} txn Opaque transaction object from cryptoStore.doTxn() + * @param {function} func + * @private + */ +OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) { + this._cryptoStore.getEndToEndSession( + deviceKey, sessionId, txn, (sessionInfo) => { + this._unpickleSession(sessionInfo, func); + }, + ); +}; + +/** + * Creates a session object from a session pickle and executes the given + * function with it. The session object is destroyed once the function + * returns. + * + * @param {object} sessionInfo + * @param {function} func + * @private + */ +OlmDevice.prototype._unpickleSession = function(sessionInfo, func) { + const session = new global.Olm.Session(); + try { + session.unpickle(this._pickleKey, sessionInfo.session); + const unpickledSessInfo = Object.assign({}, sessionInfo, {session}); + + func(unpickledSessInfo); + } finally { + session.free(); + } +}; + +/** + * store our OlmSession in the session store + * + * @param {string} deviceKey + * @param {object} sessionInfo {session: OlmSession, lastReceivedMessageTs: int} + * @param {*} txn Opaque transaction object from cryptoStore.doTxn() + * @private + */ +OlmDevice.prototype._saveSession = function(deviceKey, sessionInfo, txn) { + const sessionId = sessionInfo.session.session_id(); + const pickledSessionInfo = Object.assign(sessionInfo, { + session: sessionInfo.session.pickle(this._pickleKey), + }); + this._cryptoStore.storeEndToEndSession( + deviceKey, sessionId, pickledSessionInfo, txn, + ); +}; + + +/** + * get an OlmUtility and call the given function + * + * @param {function} func + * @return {object} result of func + * @private + */ +OlmDevice.prototype._getUtility = function(func) { + const utility = new global.Olm.Utility(); + try { + return func(utility); + } finally { + utility.free(); + } +}; + + +/** + * Signs a message with the ed25519 key for this account. + * + * @param {string} message message to be signed + * @return {Promise} base64-encoded signature + */ +OlmDevice.prototype.sign = async function(message) { + let result; + await this._cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this._getAccount(txn, (account) => { + result = account.sign(message); + }, + ); + }); + return result; +}; + +/** + * Get the current (unused, unpublished) one-time keys for this account. + * + * @return {object} one time keys; an object with the single property + * curve25519, which is itself an object mapping key id to Curve25519 + * key. + */ +OlmDevice.prototype.getOneTimeKeys = async function() { + let result; + await this._cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this._getAccount(txn, (account) => { + result = JSON.parse(account.one_time_keys()); + }); + }, + ); + + return result; +}; + + +/** + * Get the maximum number of one-time keys we can store. + * + * @return {number} number of keys + */ +OlmDevice.prototype.maxNumberOfOneTimeKeys = function() { + return this._maxOneTimeKeys; +}; + +/** + * Marks all of the one-time keys as published. + */ +OlmDevice.prototype.markKeysAsPublished = async function() { + await this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this._getAccount(txn, (account) => { + account.mark_keys_as_published(); + this._storeAccount(txn, account); + }); + }, + ); +}; + +/** + * Generate some new one-time keys + * + * @param {number} numKeys number of keys to generate + * @return {Promise} Resolved once the account is saved back having generated the keys + */ +OlmDevice.prototype.generateOneTimeKeys = function(numKeys) { + return this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this._getAccount(txn, (account) => { + account.generate_one_time_keys(numKeys); + this._storeAccount(txn, account); + }); + }, + ); +}; + +/** + * Generate a new outbound session + * + * The new session will be stored in the cryptoStore. + * + * @param {string} theirIdentityKey remote user's Curve25519 identity key + * @param {string} theirOneTimeKey remote user's one-time Curve25519 key + * @return {string} sessionId for the outbound session. + */ +OlmDevice.prototype.createOutboundSession = async function( + theirIdentityKey, theirOneTimeKey, +) { + let newSessionId; + await this._cryptoStore.doTxn( + 'readwrite', [ + IndexedDBCryptoStore.STORE_ACCOUNT, + IndexedDBCryptoStore.STORE_SESSIONS, + ], + (txn) => { + this._getAccount(txn, (account) => { + const session = new global.Olm.Session(); + try { + session.create_outbound(account, theirIdentityKey, theirOneTimeKey); + newSessionId = session.session_id(); + this._storeAccount(txn, account); + const sessionInfo = { + session, + // Pretend we've received a message at this point, otherwise + // if we try to send a message to the device, it won't use + // this session + lastReceivedMessageTs: Date.now(), + }; + this._saveSession(theirIdentityKey, sessionInfo, txn); + } finally { + session.free(); + } + }); + }, + ); + return newSessionId; +}; + + +/** + * Generate a new inbound session, given an incoming message + * + * @param {string} theirDeviceIdentityKey remote user's Curve25519 identity key + * @param {number} messageType messageType field from the received message (must be 0) + * @param {string} ciphertext base64-encoded body from the received message + * + * @return {{payload: string, session_id: string}} decrypted payload, and + * session id of new session + * + * @raises {Error} if the received message was not valid (for instance, it + * didn't use a valid one-time key). + */ +OlmDevice.prototype.createInboundSession = async function( + theirDeviceIdentityKey, messageType, ciphertext, +) { + if (messageType !== 0) { + throw new Error("Need messageType == 0 to create inbound session"); + } + + let result; + await this._cryptoStore.doTxn( + 'readwrite', [ + IndexedDBCryptoStore.STORE_ACCOUNT, + IndexedDBCryptoStore.STORE_SESSIONS, + ], + (txn) => { + this._getAccount(txn, (account) => { + const session = new global.Olm.Session(); + try { + session.create_inbound_from( + account, theirDeviceIdentityKey, ciphertext, + ); + account.remove_one_time_keys(session); + this._storeAccount(txn, account); + + const payloadString = session.decrypt(messageType, ciphertext); + + const sessionInfo = { + session, + // this counts as a received message: set last received message time + // to now + lastReceivedMessageTs: Date.now(), + }; + this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); + + result = { + payload: payloadString, + session_id: session.session_id(), + }; + } finally { + session.free(); + } + }); + }, + ); + + return result; +}; + + +/** + * Get a list of known session IDs for the given device + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * remote device + * @return {Promise} a list of known session ids for the device + */ +OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityKey) { + if (this._sessionsInProgress[theirDeviceIdentityKey]) { + logger.log("waiting for session to be created"); + try { + await this._sessionsInProgress[theirDeviceIdentityKey]; + } catch (e) { + // if the session failed to be created, just fall through and + // return an empty result + } + } + let sessionIds; + await this._cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this._cryptoStore.getEndToEndSessions( + theirDeviceIdentityKey, txn, (sessions) => { + sessionIds = Object.keys(sessions); + }, + ); + }, + ); + + return sessionIds; +}; + +/** + * Get the right olm session id for encrypting messages to the given identity key + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * remote device + * @param {boolean} nowait Don't wait for an in-progress session to complete. + * This should only be set to true of the calling function is the function + * that marked the session as being in-progress. + * @return {Promise} session id, or null if no established session + */ +OlmDevice.prototype.getSessionIdForDevice = async function( + theirDeviceIdentityKey, nowait, +) { + const sessionInfos = await this.getSessionInfoForDevice( + theirDeviceIdentityKey, nowait, + ); + + if (sessionInfos.length === 0) { + return null; + } + // Use the session that has most recently received a message + let idxOfBest = 0; + for (let i = 1; i < sessionInfos.length; i++) { + const thisSessInfo = sessionInfos[i]; + const thisLastReceived = thisSessInfo.lastReceivedMessageTs === undefined ? + 0 : thisSessInfo.lastReceivedMessageTs; + + const bestSessInfo = sessionInfos[idxOfBest]; + const bestLastReceived = bestSessInfo.lastReceivedMessageTs === undefined ? + 0 : bestSessInfo.lastReceivedMessageTs; + if ( + thisLastReceived > bestLastReceived || ( + thisLastReceived === bestLastReceived && + thisSessInfo.sessionId < bestSessInfo.sessionId + ) + ) { + idxOfBest = i; + } + } + return sessionInfos[idxOfBest].sessionId; +}; + +/** + * Get information on the active Olm sessions for a device. + *

    + * Returns an array, with an entry for each active session. The first entry in + * the result will be the one used for outgoing messages. Each entry contains + * the keys 'hasReceivedMessage' (true if the session has received an incoming + * message and is therefore past the pre-key stage), and 'sessionId'. + * + * @param {string} deviceIdentityKey Curve25519 identity key for the device + * @param {boolean} nowait Don't wait for an in-progress session to complete. + * This should only be set to true of the calling function is the function + * that marked the session as being in-progress. + * @return {Array.<{sessionId: string, hasReceivedMessage: Boolean}>} + */ +OlmDevice.prototype.getSessionInfoForDevice = async function(deviceIdentityKey, nowait) { + if (this._sessionsInProgress[deviceIdentityKey] && !nowait) { + logger.log("waiting for session to be created"); + try { + await this._sessionsInProgress[deviceIdentityKey]; + } catch (e) { + // if the session failed to be created, then just fall through and + // return an empty result + } + } + const info = []; + + await this._cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this._cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => { + const sessionIds = Object.keys(sessions).sort(); + for (const sessionId of sessionIds) { + this._unpickleSession(sessions[sessionId], (sessInfo) => { + info.push({ + lastReceivedMessageTs: sessInfo.lastReceivedMessageTs, + hasReceivedMessage: sessInfo.session.has_received_message(), + sessionId: sessionId, + }); + }); + } + }); + }, + ); + + return info; +}; + +/** + * Encrypt an outgoing message using an existing session + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * remote device + * @param {string} sessionId the id of the active session + * @param {string} payloadString payload to be encrypted and sent + * + * @return {Promise} ciphertext + */ +OlmDevice.prototype.encryptMessage = async function( + theirDeviceIdentityKey, sessionId, payloadString, +) { + checkPayloadLength(payloadString); + + let res; + await this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + res = sessionInfo.session.encrypt(payloadString); + this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); + }); + }, + ); + return res; +}; + +/** + * Decrypt an incoming message using an existing session + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * remote device + * @param {string} sessionId the id of the active session + * @param {number} messageType messageType field from the received message + * @param {string} ciphertext base64-encoded body from the received message + * + * @return {Promise} decrypted payload. + */ +OlmDevice.prototype.decryptMessage = async function( + theirDeviceIdentityKey, sessionId, messageType, ciphertext, +) { + let payloadString; + await this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + payloadString = sessionInfo.session.decrypt(messageType, ciphertext); + sessionInfo.lastReceivedMessageTs = Date.now(); + this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); + }); + }, + ); + return payloadString; +}; + +/** + * Determine if an incoming messages is a prekey message matching an existing session + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key for the + * remote device + * @param {string} sessionId the id of the active session + * @param {number} messageType messageType field from the received message + * @param {string} ciphertext base64-encoded body from the received message + * + * @return {Promise} true if the received message is a prekey message which matches + * the given session. + */ +OlmDevice.prototype.matchesSession = async function( + theirDeviceIdentityKey, sessionId, messageType, ciphertext, +) { + if (messageType !== 0) { + return false; + } + + let matches; + await this._cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_SESSIONS], + (txn) => { + this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + matches = sessionInfo.session.matches_inbound(ciphertext); + }); + }, + ); + return matches; +}; + + +// Outbound group session +// ====================== + +/** + * store an OutboundGroupSession in _outboundGroupSessionStore + * + * @param {Olm.OutboundGroupSession} session + * @private + */ +OlmDevice.prototype._saveOutboundGroupSession = function(session) { + const pickledSession = session.pickle(this._pickleKey); + this._outboundGroupSessionStore[session.session_id()] = pickledSession; +}; + + +/** + * extract an OutboundGroupSession from _outboundGroupSessionStore and call the + * given function + * + * @param {string} sessionId + * @param {function} func + * @return {object} result of func + * @private + */ +OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) { + const pickled = this._outboundGroupSessionStore[sessionId]; + if (pickled === undefined) { + throw new Error("Unknown outbound group session " + sessionId); + } + + const session = new global.Olm.OutboundGroupSession(); + try { + session.unpickle(this._pickleKey, pickled); + return func(session); + } finally { + session.free(); + } +}; + + +/** + * Generate a new outbound group session + * + * @return {string} sessionId for the outbound session. + */ +OlmDevice.prototype.createOutboundGroupSession = function() { + const session = new global.Olm.OutboundGroupSession(); + try { + session.create(); + this._saveOutboundGroupSession(session); + return session.session_id(); + } finally { + session.free(); + } +}; + + +/** + * Encrypt an outgoing message with an outbound group session + * + * @param {string} sessionId the id of the outboundgroupsession + * @param {string} payloadString payload to be encrypted and sent + * + * @return {string} ciphertext + */ +OlmDevice.prototype.encryptGroupMessage = function(sessionId, payloadString) { + const self = this; + + checkPayloadLength(payloadString); + + return this._getOutboundGroupSession(sessionId, function(session) { + const res = session.encrypt(payloadString); + self._saveOutboundGroupSession(session); + return res; + }); +}; + +/** + * Get the session keys for an outbound group session + * + * @param {string} sessionId the id of the outbound group session + * + * @return {{chain_index: number, key: string}} current chain index, and + * base64-encoded secret key. + */ +OlmDevice.prototype.getOutboundGroupSessionKey = function(sessionId) { + return this._getOutboundGroupSession(sessionId, function(session) { + return { + chain_index: session.message_index(), + key: session.session_key(), + }; + }); +}; + + +// Inbound group session +// ===================== + +/** + * data stored in the session store about an inbound group session + * + * @typedef {Object} InboundGroupSessionData + * @property {string} room_Id + * @property {string} session pickled Olm.InboundGroupSession + * @property {Object} keysClaimed + * @property {Array} forwardingCurve25519KeyChain Devices involved in forwarding + * this session to us (normally empty). + */ + +/** + * Unpickle a session from a sessionData object and invoke the given function. + * The session is valid only until func returns. + * + * @param {Object} sessionData Object describing the session. + * @param {function(Olm.InboundGroupSession)} func Invoked with the unpickled session + * @return {*} result of func + */ +OlmDevice.prototype._unpickleInboundGroupSession = function(sessionData, func) { + const session = new global.Olm.InboundGroupSession(); + try { + session.unpickle(this._pickleKey, sessionData.session); + return func(session); + } finally { + session.free(); + } +}; + +/** + * extract an InboundGroupSession from the crypto store and call the given function + * + * @param {string} roomId The room ID to extract the session for, or null to fetch + * sessions for any room. + * @param {string} senderKey + * @param {string} sessionId + * @param {*} txn Opaque transaction object from cryptoStore.doTxn() + * @param {function(Olm.InboundGroupSession, InboundGroupSessionData)} func + * function to call. + * + * @private + */ +OlmDevice.prototype._getInboundGroupSession = function( + roomId, senderKey, sessionId, txn, func, +) { + this._cryptoStore.getEndToEndInboundGroupSession( + senderKey, sessionId, txn, (sessionData) => { + if (sessionData === null) { + func(null); + return; + } + + // if we were given a room ID, check that the it matches the original one for the session. This stops + // the HS pretending a message was targeting a different room. + if (roomId !== null && roomId !== sessionData.room_id) { + throw new Error( + "Mismatched room_id for inbound group session (expected " + + sessionData.room_id + ", was " + roomId + ")", + ); + } + + this._unpickleInboundGroupSession(sessionData, (session) => { + func(session, sessionData); + }); + }, + ); +}; + +/** + * Add an inbound group session to the session store + * + * @param {string} roomId room in which this session will be used + * @param {string} senderKey base64-encoded curve25519 key of the sender + * @param {Array} forwardingCurve25519KeyChain Devices involved in forwarding + * this session to us. + * @param {string} sessionId session identifier + * @param {string} sessionKey base64-encoded secret key + * @param {Object} keysClaimed Other keys the sender claims. + * @param {boolean} exportFormat true if the megolm keys are in export format + * (ie, they lack an ed25519 signature) + */ +OlmDevice.prototype.addInboundGroupSession = async function( + roomId, senderKey, forwardingCurve25519KeyChain, + sessionId, sessionKey, keysClaimed, + exportFormat, +) { + await this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + /* if we already have this session, consider updating it */ + this._getInboundGroupSession( + roomId, senderKey, sessionId, txn, + (existingSession, existingSessionData) => { + // new session. + const session = new global.Olm.InboundGroupSession(); + try { + if (exportFormat) { + session.import_session(sessionKey); + } else { + session.create(sessionKey); + } + if (sessionId != session.session_id()) { + throw new Error( + "Mismatched group session ID from senderKey: " + + senderKey, + ); + } + + if (existingSession) { + logger.log( + "Update for megolm session " + + senderKey + "/" + sessionId, + ); + if (existingSession.first_known_index() + <= session.first_known_index()) { + // existing session has lower index (i.e. can + // decrypt more), so keep it + logger.log("Keeping existing session"); + return; + } + } + + const sessionData = { + room_id: roomId, + session: session.pickle(this._pickleKey), + keysClaimed: keysClaimed, + forwardingCurve25519KeyChain: forwardingCurve25519KeyChain, + }; + + this._cryptoStore.storeEndToEndInboundGroupSession( + senderKey, sessionId, sessionData, txn, + ); + } finally { + session.free(); + } + }, + ); + }, + ); +}; + +/** + * Decrypt a received message with an inbound group session + * + * @param {string} roomId room in which the message was received + * @param {string} senderKey base64-encoded curve25519 key of the sender + * @param {string} sessionId session identifier + * @param {string} body base64-encoded body of the encrypted message + * @param {string} eventId ID of the event being decrypted + * @param {Number} timestamp timestamp of the event being decrypted + * + * @return {null} the sessionId is unknown + * + * @return {Promise<{result: string, senderKey: string, + * forwardingCurve25519KeyChain: Array, + * keysClaimed: Object}>} + */ +OlmDevice.prototype.decryptGroupMessage = async function( + roomId, senderKey, sessionId, body, eventId, timestamp, +) { + let result; + + await this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + this._getInboundGroupSession( + roomId, senderKey, sessionId, txn, (session, sessionData) => { + if (session === null) { + result = null; + return; + } + const res = session.decrypt(body); + + let plaintext = res.plaintext; + if (plaintext === undefined) { + // Compatibility for older olm versions. + plaintext = res; + } else { + // Check if we have seen this message index before to detect replay attacks. + // If the event ID and timestamp are specified, and the match the event ID + // and timestamp from the last time we used this message index, then we + // don't consider it a replay attack. + const messageIndexKey = ( + senderKey + "|" + sessionId + "|" + res.message_index + ); + if (messageIndexKey in this._inboundGroupSessionMessageIndexes) { + const msgInfo = ( + this._inboundGroupSessionMessageIndexes[messageIndexKey] + ); + if ( + msgInfo.id !== eventId || + msgInfo.timestamp !== timestamp + ) { + throw new Error( + "Duplicate message index, possible replay attack: " + + messageIndexKey, + ); + } + } + this._inboundGroupSessionMessageIndexes[messageIndexKey] = { + id: eventId, + timestamp: timestamp, + }; + } + + sessionData.session = session.pickle(this._pickleKey); + this._cryptoStore.storeEndToEndInboundGroupSession( + senderKey, sessionId, sessionData, txn, + ); + result = { + result: plaintext, + keysClaimed: sessionData.keysClaimed || {}, + senderKey: senderKey, + forwardingCurve25519KeyChain: ( + sessionData.forwardingCurve25519KeyChain || [] + ), + }; + }, + ); + }, + ); + + return result; +}; + +/** + * Determine if we have the keys for a given megolm session + * + * @param {string} roomId room in which the message was received + * @param {string} senderKey base64-encoded curve25519 key of the sender + * @param {sring} sessionId session identifier + * + * @returns {Promise} true if we have the keys to this session + */ +OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, sessionId) { + let result; + await this._cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + this._cryptoStore.getEndToEndInboundGroupSession( + senderKey, sessionId, txn, (sessionData) => { + if (sessionData === null) { + result = false; + return; + } + + if (roomId !== sessionData.room_id) { + logger.warn( + `requested keys for inbound group session ${senderKey}|` + + `${sessionId}, with incorrect room_id ` + + `(expected ${sessionData.room_id}, ` + + `was ${roomId})`, + ); + result = false; + } else { + result = true; + } + }, + ); + }, + ); + + return result; +}; + +/** + * Extract the keys to a given megolm session, for sharing + * + * @param {string} roomId room in which the message was received + * @param {string} senderKey base64-encoded curve25519 key of the sender + * @param {string} sessionId session identifier + * @param {integer} chainIndex The chain index at which to export the session. + * If omitted, export at the first index we know about. + * + * @returns {Promise<{chain_index: number, key: string, + * forwarding_curve25519_key_chain: Array, + * sender_claimed_ed25519_key: string + * }>} + * details of the session key. The key is a base64-encoded megolm key in + * export format. + * + * @throws Error If the given chain index could not be obtained from the known + * index (ie. the given chain index is before the first we have). + */ +OlmDevice.prototype.getInboundGroupSessionKey = async function( + roomId, senderKey, sessionId, chainIndex, +) { + let result; + await this._cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + this._getInboundGroupSession( + roomId, senderKey, sessionId, txn, (session, sessionData) => { + if (session === null) { + result = null; + return; + } + + if (chainIndex === undefined) { + chainIndex = session.first_known_index(); + } + + const exportedSession = session.export_session(chainIndex); + + const claimedKeys = sessionData.keysClaimed || {}; + const senderEd25519Key = claimedKeys.ed25519 || null; + + result = { + "chain_index": chainIndex, + "key": exportedSession, + "forwarding_curve25519_key_chain": + sessionData.forwardingCurve25519KeyChain || [], + "sender_claimed_ed25519_key": senderEd25519Key, + }; + }, + ); + }, + ); + + return result; +}; + +/** + * Export an inbound group session + * + * @param {string} senderKey base64-encoded curve25519 key of the sender + * @param {string} sessionId session identifier + * @param {string} sessionData The session object from the store + * @return {module:crypto/OlmDevice.MegolmSessionData} exported session data + */ +OlmDevice.prototype.exportInboundGroupSession = function( + senderKey, sessionId, sessionData, +) { + return this._unpickleInboundGroupSession(sessionData, (session) => { + const messageIndex = session.first_known_index(); + + return { + "sender_key": senderKey, + "sender_claimed_keys": sessionData.keysClaimed, + "room_id": sessionData.room_id, + "session_id": sessionId, + "session_key": session.export_session(messageIndex), + "forwarding_curve25519_key_chain": session.forwardingCurve25519KeyChain || [], + "first_known_index": session.first_known_index(), + }; + }); +}; + +// Utilities +// ========= + +/** + * Verify an ed25519 signature. + * + * @param {string} key ed25519 key + * @param {string} message message which was signed + * @param {string} signature base64-encoded signature to be checked + * + * @raises {Error} if there is a problem with the verification. If the key was + * too small then the message will be "OLM.INVALID_BASE64". If the signature + * was invalid then the message will be "OLM.BAD_MESSAGE_MAC". + */ +OlmDevice.prototype.verifySignature = function( + key, message, signature, +) { + this._getUtility(function(util) { + util.ed25519_verify(key, message, signature); + }); +}; + +/** */ +module.exports = OlmDevice; diff --git a/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.js b/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.js new file mode 100644 index 000000000..3af19f3c9 --- /dev/null +++ b/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.js @@ -0,0 +1,493 @@ +/* +Copyright 2017 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. +*/ + +import Promise from 'bluebird'; + +import logger from '../logger'; +import utils from '../utils'; + +/** + * Internal module. Management of outgoing room key requests. + * + * See https://docs.google.com/document/d/1m4gQkcnJkxNuBmb5NoFCIadIY-DyqqNAS3lloE73BlQ + * for draft documentation on what we're supposed to be implementing here. + * + * @module + */ + +// delay between deciding we want some keys, and sending out the request, to +// allow for (a) it turning up anyway, (b) grouping requests together +const SEND_KEY_REQUESTS_DELAY_MS = 500; + +/** possible states for a room key request + * + * The state machine looks like: + * + * | (cancellation sent) + * | .-------------------------------------------------. + * | | | + * V V (cancellation requested) | + * UNSENT -----------------------------+ | + * | | | + * | | | + * | (send successful) | CANCELLATION_PENDING_AND_WILL_RESEND + * V | Λ + * SENT | | + * |-------------------------------- | --------------' + * | | (cancellation requested with intent + * | | to resend the original request) + * | | + * | (cancellation requested) | + * V | + * CANCELLATION_PENDING | + * | | + * | (cancellation sent) | + * V | + * (deleted) <---------------------------+ + * + * @enum {number} + */ +const ROOM_KEY_REQUEST_STATES = { + /** request not yet sent */ + UNSENT: 0, + + /** request sent, awaiting reply */ + SENT: 1, + + /** reply received, cancellation not yet sent */ + CANCELLATION_PENDING: 2, + + /** + * 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, +}; + +export default 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. + this._sendOutgoingRoomKeyRequestsTimer = null; + + // sanity check to ensure that we don't end up with two concurrent runs + // of _sendOutgoingRoomKeyRequests + this._sendOutgoingRoomKeyRequestsRunning = false; + + this._clientRunning = false; + } + + /** + * Called when the client is started. Sets background processes running. + */ + start() { + this._clientRunning = true; + + // set the timer going, to handle any requests which didn't get sent + // on the previous run of the client. + this._startTimer(); + } + + /** + * Called when the client is stopped. Stops any running background processes. + */ + stop() { + logger.log('stopping OutgoingRoomKeyRequestManager'); + // stop the timer on the next run + this._clientRunning = false; + } + + /** + * Send off a room key request, if we haven't already done so. + * + * The `requestBody` is compared (with a deep-equality check) against + * previous queued or sent requests and if it matches, no change is made. + * Otherwise, a request is added to the pending list, and a job is started + * in the background to send it. + * + * @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 + * + * @returns {Promise} resolves when the request has been added to the + * pending list (or we have established that a similar request already + * exists) + */ + async sendRoomKeyRequest(requestBody, recipients, resend=false) { + const req = await this._cryptoStore.getOutgoingRoomKeyRequest( + requestBody, + ); + if (!req) { + await this._cryptoStore.getOrAddOutgoingRoomKeyRequest({ + requestBody: requestBody, + recipients: recipients, + requestId: this._baseApis.makeTxnId(), + state: ROOM_KEY_REQUEST_STATES.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 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.sendRoomKeyRequest( + 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); + } + } + // some of the branches require the timer to be started. Just start it + // all the time, because it doesn't hurt to start it. + this._startTimer(); + } + + /** + * Cancel room key requests, if any match the given requestBody + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * + * @returns {Promise} resolves when the request has been updated in our + * pending list. + */ + cancelRoomKeyRequest(requestBody) { + return this._cryptoStore.getOutgoingRoomKeyRequest( + requestBody, + ).then((req) => { + if (!req) { + // no request was made for this key + return; + } + switch (req.state) { + case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: + case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: + // nothing to do here + return; + + case ROOM_KEY_REQUEST_STATES.UNSENT: + // just delete it + + // FIXME: ghahah we may have attempted to send it, and + // not yet got a successful response. So the server + // may have seen it, so we still need to send a cancellation + // in that case :/ + + logger.log( + 'deleting unnecessary room key request for ' + + stringifyRequestBody(requestBody), + ); + return this._cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT, + ); + + case ROOM_KEY_REQUEST_STATES.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(), + }, + ).then((updatedReq) => { + 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. There is no point in + // sending another cancellation since the other tab + // will do it. + logger.log( + 'Tried to cancel room key request for ' + + stringifyRequestBody(requestBody) + + ' but it was already cancelled in another tab', + ); + return; + } + + // 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.) + this._sendOutgoingRoomKeyRequestCancellation( + updatedReq, + ).catch((e) => { + logger.error( + "Error sending room key request cancellation;" + + " will retry later.", e, + ); + this._startTimer(); + }); + }); + } + default: + throw new Error('unhandled state: ' + req.state); + } + }); + } + + /** + * Look for room key requests by target device and state + * + * @param {string} userId Target user ID + * @param {string} deviceId Target device ID + * + * @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], + ); + } + + // start the background timer to send queued requests, if the timer isn't + // already running + _startTimer() { + if (this._sendOutgoingRoomKeyRequestsTimer) { + return; + } + + const startSendingOutgoingRoomKeyRequests = () => { + if (this._sendOutgoingRoomKeyRequestsRunning) { + throw new Error("RoomKeyRequestSend already in progress!"); + } + this._sendOutgoingRoomKeyRequestsRunning = true; + + 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. + logger.warn( + `error in OutgoingRoomKeyRequestManager: ${e}`, + ); + }); + }; + + this._sendOutgoingRoomKeyRequestsTimer = global.setTimeout( + startSendingOutgoingRoomKeyRequests, + SEND_KEY_REQUESTS_DELAY_MS, + ); + } + + // 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; + return Promise.resolve(); + } + + logger.log("Looking for queued outgoing room key requests"); + + 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) => { + if (!req) { + logger.log("No more outgoing room key requests"); + this._sendOutgoingRoomKeyRequestsTimer = null; + return; + } + + let prom; + switch (req.state) { + case ROOM_KEY_REQUEST_STATES.UNSENT: + prom = this._sendOutgoingRoomKeyRequest(req); + break; + case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: + prom = this._sendOutgoingRoomKeyRequestCancellation(req); + break; + case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: + prom = this._sendOutgoingRoomKeyRequestCancellation(req, true); + break; + } + + return prom.then(() => { + // go around the loop again + return this._sendOutgoingRoomKeyRequests(); + }).catch((e) => { + logger.error("Error sending room key request; will retry later.", e); + this._sendOutgoingRoomKeyRequestsTimer = null; + this._startTimer(); + }); + }); + } + + // given a RoomKeyRequest, send it and update the request record + _sendOutgoingRoomKeyRequest(req) { + logger.log( + `Requesting keys for ${stringifyRequestBody(req.requestBody)}` + + ` from ${stringifyRecipientList(req.recipients)}` + + `(id ${req.requestId})`, + ); + + const requestMessage = { + action: "request", + requesting_device_id: this._deviceId, + request_id: req.requestId, + body: req.requestBody, + }; + + 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 }, + ); + }); + } + + // Given a RoomKeyRequest, cancel it and delete the request record unless + // andResend is set, in which case transition to UNSENT. + _sendOutgoingRoomKeyRequestCancellation(req, andResend) { + logger.log( + `Sending cancellation for key request for ` + + `${stringifyRequestBody(req.requestBody)} to ` + + `${stringifyRecipientList(req.recipients)} ` + + `(cancellation id ${req.cancellationTxnId})`, + ); + + const requestMessage = { + action: "request_cancellation", + requesting_device_id: this._deviceId, + request_id: req.requestId, + }; + + return this._sendMessageToDevices( + requestMessage, req.recipients, req.cancellationTxnId, + ).then(() => { + if (andResend) { + // We want to resend, so transition to UNSENT + return this._cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, + ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND, + { state: ROOM_KEY_REQUEST_STATES.UNSENT }, + ); + } + return this._cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, + ); + }); + } + + // send a RoomKeyRequest to a list of recipients + _sendMessageToDevices(message, recipients, txnId) { + const contentMap = {}; + for (const recip of recipients) { + if (!contentMap[recip.userId]) { + contentMap[recip.userId] = {}; + } + contentMap[recip.userId][recip.deviceId] = message; + } + + return this._baseApis.sendToDevice( + 'm.room_key_request', contentMap, txnId, + ); + } +} + +function stringifyRequestBody(requestBody) { + // we assume that the request is for megolm keys, which are identified by + // room id and session id + return requestBody.room_id + " / " + requestBody.session_id; +} + +function stringifyRecipientList(recipients) { + return '[' + + utils.map(recipients, (r) => `${r.userId}:${r.deviceId}`).join(",") + + ']'; +} + diff --git a/matrix-js-sdk/src/crypto/RoomList.js b/matrix-js-sdk/src/crypto/RoomList.js new file mode 100644 index 000000000..b8c87dd1f --- /dev/null +++ b/matrix-js-sdk/src/crypto/RoomList.js @@ -0,0 +1,65 @@ +/* +Copyright 2018, 2019 New Vector 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. +*/ + +/** + * @module crypto/RoomList + * + * Manages the list of encrypted rooms + */ + +import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; + +/** + * @alias module:crypto/RoomList + */ +export default class RoomList { + constructor(cryptoStore) { + this._cryptoStore = cryptoStore; + + // Object of roomId -> room e2e info object (body of the m.room.encryption event) + this._roomEncryption = {}; + } + + async init() { + await this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { + this._cryptoStore.getEndToEndRooms(txn, (result) => { + this._roomEncryption = result; + }); + }, + ); + } + + getRoomEncryption(roomId) { + return this._roomEncryption[roomId] || null; + } + + isRoomEncrypted(roomId) { + return Boolean(this.getRoomEncryption(roomId)); + } + + async setRoomEncryption(roomId, roomInfo) { + // 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( + 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { + this._cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn); + }, + ); + } +} diff --git a/matrix-js-sdk/src/crypto/algorithms/base.js b/matrix-js-sdk/src/crypto/algorithms/base.js new file mode 100644 index 000000000..09b15c9cf --- /dev/null +++ b/matrix-js-sdk/src/crypto/algorithms/base.js @@ -0,0 +1,236 @@ +/* +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. +*/ + +/** + * Internal module. Defines the base classes of the encryption implementations + * + * @module + */ + +import Promise from 'bluebird'; + +/** + * map of registered encryption algorithm classes. A map from string to {@link + * module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} class + * + * @type {Map.} + */ +export const ENCRYPTION_CLASSES = new Map(); + +/** + * map of registered encryption algorithm classes. Map from string to {@link + * module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm} class + * + * @type {Map.} + */ +export const DECRYPTION_CLASSES = new Map(); + +/** + * base type for encryption implementations + * + * @alias module:crypto/algorithms/base.EncryptionAlgorithm + * + * @param {object} params parameters + * @param {string} params.userId The UserID for the local user + * @param {string} params.deviceId The identifier for this device. + * @param {module:crypto} params.crypto crypto core + * @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper + * @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface + * @param {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 + */ +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; + } + + /** + * Encrypt a message event + * + * @method module:crypto/algorithms/base.EncryptionAlgorithm.encryptMessage + * @abstract + * + * @param {module:models/room} room + * @param {string} eventType + * @param {object} plaintext event content + * + * @return {module:client.Promise} Promise which resolves to the new event body + */ + + /** + * Called when the membership of a member of the room changes. + * + * @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 + * @public + */ + onRoomMembership(event, member, oldMembership) { + } +} +export {EncryptionAlgorithm}; // https://github.com/jsdoc3/jsdoc/issues/1272 + +/** + * base type for decryption implementations + * + * @alias module:crypto/algorithms/base.DecryptionAlgorithm + * @param {object} params parameters + * @param {string} params.userId The UserID for the local user + * @param {module:crypto} params.crypto crypto core + * @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper + * @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface + * @param {string=} params.roomId The ID of the room we will be receiving + * from. Null for to-device events. + */ +class DecryptionAlgorithm { + constructor(params) { + this._userId = params.userId; + this._crypto = params.crypto; + this._olmDevice = params.olmDevice; + this._baseApis = params.baseApis; + this._roomId = params.roomId; + } + + /** + * Decrypt an event + * + * @method module:crypto/algorithms/base.DecryptionAlgorithm#decryptEvent + * @abstract + * + * @param {MatrixEvent} event undecrypted event + * + * @return {Promise} promise which + * resolves once we have finished decrypting. Rejects with an + * `algorithms.DecryptionError` if there is a problem decrypting the event. + */ + + /** + * Handle a key event + * + * @method module:crypto/algorithms/base.DecryptionAlgorithm#onRoomKeyEvent + * + * @param {module:models/event.MatrixEvent} params event key event + */ + onRoomKeyEvent(params) { + // ignore by default + } + + /** + * Import a room key + * + * @param {module:crypto/OlmDevice.MegolmSessionData} session + */ + importRoomKey(session) { + // ignore by default + } + + /** + * Determine if we have the keys necessary to respond to a room key request + * + * @param {module:crypto~IncomingRoomKeyRequest} keyRequest + * @return {Promise} true if we have the keys and could (theoretically) share + * them; else false. + */ + hasKeysForKeyRequest(keyRequest) { + return Promise.resolve(false); + } + + /** + * Send the response to a room key request + * + * @param {module:crypto~IncomingRoomKeyRequest} keyRequest + */ + shareKeysWithDevice(keyRequest) { + throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); + } +} +export {DecryptionAlgorithm}; // https://github.com/jsdoc3/jsdoc/issues/1272 + +/** + * Exception thrown when decryption fails + * + * @alias module:crypto/algorithms/base.DecryptionError + * @param {string} msg user-visible message describing the problem + * + * @param {Object=} details key/value pairs reported in the logs but not shown + * to the user. + * + * @extends Error + */ +class DecryptionError extends Error { + constructor(code, msg, details) { + super(msg); + this.code = code; + this.name = 'DecryptionError'; + this.detailedString = _detailedStringForDecryptionError(this, details); + } +} +export {DecryptionError}; // https://github.com/jsdoc3/jsdoc/issues/1272 + +function _detailedStringForDecryptionError(err, details) { + let result = err.name + '[msg: ' + err.message; + + if (details) { + result += ', ' + + Object.keys(details).map( + (k) => k + ': ' + details[k], + ).join(', '); + } + + result += ']'; + + return result; +} + +/** + * Exception thrown specifically when we want to warn the user to consider + * the security of their conversation before continuing + * + * @param {string} msg message describing the problem + * @param {Object} devices userId -> {deviceId -> object} + * set of unknown devices per user we're warning about + * @extends Error + */ +export class UnknownDeviceError extends Error { + constructor(msg, devices) { + super(msg); + this.name = "UnknownDeviceError"; + this.devices = devices; + } +} + +/** + * Registers an encryption/decryption class for a particular algorithm + * + * @param {string} algorithm algorithm tag to register for + * + * @param {class} encryptor {@link + * module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} + * implementation + * + * @param {class} decryptor {@link + * module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm} + * implementation + */ +export function registerAlgorithm(algorithm, encryptor, decryptor) { + ENCRYPTION_CLASSES.set(algorithm, encryptor); + DECRYPTION_CLASSES.set(algorithm, decryptor); +} diff --git a/matrix-js-sdk/src/crypto/algorithms/index.js b/matrix-js-sdk/src/crypto/algorithms/index.js new file mode 100644 index 000000000..ce64b3b11 --- /dev/null +++ b/matrix-js-sdk/src/crypto/algorithms/index.js @@ -0,0 +1,40 @@ +/* +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. +*/ +"use strict"; + +/** + * @module crypto/algorithms + */ + +const base = require("./base"); + +require("./olm"); +require("./megolm"); + +/** + * @see module:crypto/algorithms/base.ENCRYPTION_CLASSES + */ +module.exports.ENCRYPTION_CLASSES = base.ENCRYPTION_CLASSES; + +/** + * @see module:crypto/algorithms/base.DECRYPTION_CLASSES + */ +module.exports.DECRYPTION_CLASSES = base.DECRYPTION_CLASSES; + +/** + * @see module:crypto/algorithms/base.DecryptionError + */ +module.exports.DecryptionError = base.DecryptionError; diff --git a/matrix-js-sdk/src/crypto/algorithms/megolm.js b/matrix-js-sdk/src/crypto/algorithms/megolm.js new file mode 100644 index 000000000..3ce68eae2 --- /dev/null +++ b/matrix-js-sdk/src/crypto/algorithms/megolm.js @@ -0,0 +1,1143 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector 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. +*/ +"use strict"; + +/** + * Defines m.olm encryption/decryption + * + * @module crypto/algorithms/megolm + */ + +import Promise from 'bluebird'; +import logger from '../../../src/logger'; + +const utils = require("../../utils"); +const olmlib = require("../olmlib"); +const base = require("./base"); + +/** + * @private + * @constructor + * + * @param {string} sessionId + * + * @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 -> SharedWithData} + */ +function OutboundSessionInfo(sessionId) { + this.sessionId = sessionId; + this.useCount = 0; + this.creationTime = new Date().getTime(); + this.sharedWithDevices = {}; +} + + +/** + * 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, deviceKey, chainIndex, +) { + if (!this.sharedWithDevices[userId]) { + this.sharedWithDevices[userId] = {}; + } + this.sharedWithDevices[userId][deviceId] = { deviceKey, messageIndex: chainIndex }; +}; + +/** + * 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 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 session because we shared with " + + userId + ":" + deviceId, + ); + return true; + } + } + } +}; + + +/** + * Megolm encryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/base.EncryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/base.EncryptionAlgorithm} + */ +function MegolmEncryption(params) { + base.EncryptionAlgorithm.call(this, 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, base.EncryptionAlgorithm); + +/** + * @private + * + * @param {Object} devicesInRoom The devices in this room, indexed by user ID + * + * @return {module:client.Promise} Promise which resolves to the + * OutboundSessionInfo when setup is complete. + */ +MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) { + const self = this; + + 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. + async function prepareSession(oldSession) { + session = oldSession; + + // need to make a brand new session? + if (session && session.needsRotation(self._sessionRotationPeriodMsgs, + self._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 ${self._roomId}`); + session = await self._prepareNewSession(); + self._outboundSessions[session.sessionId] = session; + } + + // now check if we need to share with any devices + const shareMap = {}; + + for (const userId in devicesInRoom) { + if (!devicesInRoom.hasOwnProperty(userId)) { + continue; + } + + const userDevices = devicesInRoom[userId]; + + for (const deviceId in userDevices) { + if (!userDevices.hasOwnProperty(deviceId)) { + continue; + } + + const deviceInfo = userDevices[deviceId]; + + const key = deviceInfo.getIdentityKey(); + if (key == self._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); + } + } + } + + return self._shareKeyWithDevices( + session, shareMap, + ); + } + + // 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); + + // _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 + * + * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session + */ +MegolmEncryption.prototype._prepareNewSession = async function() { + 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}, + ); + + if (this._crypto.backupInfo) { + // don't wait for it to complete + this._crypto.backupGroupSession( + this._roomId, this._olmDevice.deviceCurve25519Key, [], + sessionId, key.key, + ).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 group session", e); + }); + } + + return new OutboundSessionInfo(sessionId); +}; + +/** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {number} chainIndex current chain index + * + * @param {object} devicemap + * mapping from userId to deviceId to {@link module:crypto~OlmSessionResult} + * + * @param {object} devicesByUser + * map from userid to list of devices + * + * @return {array>} + */ +MegolmEncryption.prototype._splitUserDeviceMap = function( + session, chainIndex, devicemap, devicesByUser, +) { + const maxToDeviceMessagesPerRequest = 20; + + // use an array where the slices of a content map gets stored + const mapSlices = []; + let currentSliceId = 0; // start inserting in the first slice + let entriesInCurrentSlice = 0; + + for (const userId of Object.keys(devicesByUser)) { + const devicesToShareWith = devicesByUser[userId]; + const sessionResults = devicemap[userId]; + + for (let i = 0; i < devicesToShareWith.length; i++) { + const deviceInfo = devicesToShareWith[i]; + const deviceId = deviceInfo.deviceId; + + const sessionResult = sessionResults[deviceId]; + if (!sessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + // + // we could send them a to_device message anyway, as a + // signal that they have missed out on the key sharing + // message because of the lack of keys, but there's not + // much point in that really; it will mostly serve to clog + // up to_device inboxes. + + // mark this device as "handled" because we don't want to try + // to claim a one-time-key for dead devices on every message. + session.markSharedWithDevice(userId, deviceId, deviceInfo.getIdentityKey(), chainIndex); + + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + continue; + } + + logger.log( + "share keys with device " + userId + ":" + deviceId, + ); + + if (entriesInCurrentSlice > maxToDeviceMessagesPerRequest) { + // the current slice is filled up. Start inserting into the next slice + entriesInCurrentSlice = 0; + currentSliceId++; + } + if (!mapSlices[currentSliceId]) { + mapSlices[currentSliceId] = []; + } + + mapSlices[currentSliceId].push({ + userId: userId, + deviceInfo: deviceInfo, + }); + + entriesInCurrentSlice++; + } + } + 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 {module:client.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 encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this._olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + const contentMap = {}; + const deviceInfoByDeviceId = new Map(); + + const promises = []; + for (let i = 0; i < userDeviceMap.length; i++) { + const val = userDeviceMap[i]; + const userId = val.userId; + const deviceInfo = val.deviceInfo; + const deviceId = deviceInfo.deviceId; + deviceInfoByDeviceId.set(deviceId, deviceInfo); + + 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(() => { + 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, + deviceInfoByDeviceId.get(deviceId).getIdentityKey(), + chainIndex, + ); + } + } + }); + }); +}; + +/** + * 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("Session ID " + 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("Session ID " + sessionId + " never shared with user " + userId); + return; + } + const sessionSharedData = obSessionInfo.sharedWithDevices[userId][device.deviceId]; + if (sessionSharedData === undefined) { + logger.debug( + "Session ID " + sessionId + " never shared with device " + + userId + ":" + device.deviceId, + ); + return; + } + + if (sessionSharedData.deviceKey !== device.getIdentityKey()) { + logger.warn( + `Session has been shared with device ${device.deviceId} but with identity ` + + `key ${sessionSharedData.deviceKey}. Key is now ${device.getIdentityKey()}!`, + ); + 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, sessionSharedData.messageIndex, + ); + + if (!key) { + logger.warn( + "No outbound session key found for " + sessionId + ": not re-sharing keys", + ); + return; + } + + await olmlib.ensureOlmSessionsForDevices( + this._olmDevice, this._baseApis, { + [userId]: { + [device.deviceId]: 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, + }, + }; + + 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 session ${sessionId} with ${userId}:${device.deviceId}`, + ); +}; + +/** + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} devicesByUser + * map from userid to list of devices + */ +MegolmEncryption.prototype._shareKeyWithDevices = async function(session, devicesByUser) { + 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, + }, + }; + + const devicemap = await olmlib.ensureOlmSessionsForDevices( + this._olmDevice, this._baseApis, devicesByUser, + ); + + const userDeviceMaps = this._splitUserDeviceMap( + session, key.chain_index, devicemap, devicesByUser, + ); + + for (let i = 0; i < userDeviceMaps.length; i++) { + try { + await this._encryptAndSendKeysToDevices( + session, key.chain_index, userDeviceMaps[i], payload, + ); + logger.log(`Completed megolm keyshare in ${this._roomId} ` + + `(slice ${i + 1}/${userDeviceMaps.length})`); + } catch (e) { + logger.log(`megolm keyshare in ${this._roomId} ` + + `(slice ${i + 1}/${userDeviceMaps.length}) failed`); + + throw e; + } + } +}; + +/** + * @inheritdoc + * + * @param {module:models/room} room + * @param {string} eventType + * @param {object} content plaintext event content + * + * @return {module:client.Promise} Promise which resolves to the new event body + */ +MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) { + const self = this; + logger.log(`Starting to encrypt event for ${this._roomId}`); + + return this._getDevicesInRoom(room).then(function(devicesInRoom) { + // check if any of these devices are not yet known to the user. + // if so, warn the user so they can verify or ignore. + self._checkForUnknownDevices(devicesInRoom); + + return self._ensureOutboundSession(devicesInRoom); + }).then(function(session) { + const payloadJson = { + room_id: self._roomId, + type: eventType, + content: content, + }; + + const ciphertext = self._olmDevice.encryptGroupMessage( + session.sessionId, JSON.stringify(payloadJson), + ); + + const encryptedContent = { + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: self._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: self._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 base.UnknownDeviceError( + "This room contains unknown devices which have not been verified. " + + "We strongly recommend you verify them before continuing.", unknownDevices); + } +}; + +/** + * Get the list of unblocked devices for all users in the room + * + * @param {module:models/room} room + * + * @return {module:client.Promise} Promise which resolves to a map + * from userId to deviceId to deviceInfo + */ +MegolmEncryption.prototype._getDevicesInRoom = async function(room) { + const members = await room.getEncryptionTargetMembers(); + const roomMembers = utils.map(members, 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/riot-web/issues/2305 for details. + const devices = await this._crypto.downloadKeys(roomMembers, false); + // 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; + } + + if (userDevices[deviceId].isBlocked() || + (userDevices[deviceId].isUnverified() && isBlacklisting) + ) { + delete userDevices[deviceId]; + } + } + } + + return devices; +}; + +/** + * Megolm decryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/base.DecryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/base.DecryptionAlgorithm} + */ +function MegolmDecryption(params) { + base.DecryptionAlgorithm.call(this, params); + + // events which we couldn't decrypt due to unknown sessions / indexes: map from + // senderKey|sessionId to Set of MatrixEvents + this._pendingEvents = new Map(); + + // this gets stubbed out by the unit tests. + this.olmlib = olmlib; +} +utils.inherits(MegolmDecryption, base.DecryptionAlgorithm); + +/** + * @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 base.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/riot-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) { + 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 base.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); + throw new base.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 base.DecryptionError( + "MEGOLM_BAD_ROOM", + "Message intended for room " + payload.room_id, + ); + } + + return { + clearEvent: payload, + senderCurve25519Key: res.senderKey, + claimedEd25519Key: res.keysClaimed.ed25519, + forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, + }; +}; + +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 k = content.sender_key + "|" + content.session_id; + if (!this._pendingEvents.has(k)) { + this._pendingEvents.set(k, new Set()); + } + this._pendingEvents.get(k).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 k = content.sender_key + "|" + content.session_id; + if (!this._pendingEvents.has(k)) { + return; + } + + this._pendingEvents.get(k).delete(event); + if (this._pendingEvents.get(k).size === 0) { + this._pendingEvents.delete(k); + } +}; + + +/** + * @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 (!utils.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(); + } + + logger.log(`Adding key for megolm session ${senderKey}|${sessionId}`); + return this._olmDevice.addInboundGroupSession( + content.room_id, senderKey, forwardingKeyChain, sessionId, + content.session_key, keysClaimed, + exportFormat, + ).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(() => { + if (this._crypto.backupInfo) { + // don't wait for the keys to be backed up for the server + this._crypto.backupGroupSession( + content.room_id, senderKey, forwardingKeyChain, + content.session_id, content.session_key, keysClaimed, + exportFormat, + ).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 group session", e); + }); + } + }).catch((e) => { + logger.error(`Error handling m.room_key_event: ${e}`); + }); +}; + +/** + * @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); + }); + }).done(); +}; + +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, + }, + }; +}; + +/** + * @inheritdoc + * + * @param {module:crypto/OlmDevice.MegolmSessionData} session + */ +MegolmDecryption.prototype.importRoomKey = function(session) { + 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, + ).then(() => { + if (this._crypto.backupInfo) { + // don't wait for it to complete + this._crypto.backupGroupSession( + session.room_id, + session.sender_key, + session.forwarding_curve25519_key_chain, + session.session_id, + session.session_key, + session.sender_claimed_keys, + true, + ).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 group 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 + * + * @private + * @param {String} senderKey + * @param {String} sessionId + * + * @return {Boolean} whether all messages were successfully decrypted + */ +MegolmDecryption.prototype._retryDecryption = async function(senderKey, sessionId) { + const k = senderKey + "|" + sessionId; + const pending = this._pendingEvents.get(k); + if (!pending) { + return true; + } + + this._pendingEvents.delete(k); + + 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.has(k); +}; + +base.registerAlgorithm( + olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption, +); diff --git a/matrix-js-sdk/src/crypto/algorithms/olm.js b/matrix-js-sdk/src/crypto/algorithms/olm.js new file mode 100644 index 000000000..f233e8f9f --- /dev/null +++ b/matrix-js-sdk/src/crypto/algorithms/olm.js @@ -0,0 +1,340 @@ +/* +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. +*/ +"use strict"; + +/** + * Defines m.olm encryption/decryption + * + * @module crypto/algorithms/olm + */ +import Promise from 'bluebird'; + +import logger from '../../logger'; +const utils = require("../../utils"); +const olmlib = require("../olmlib"); +const DeviceInfo = require("../deviceinfo"); +const DeviceVerification = DeviceInfo.DeviceVerification; + + +const base = require("./base"); + +/** + * Olm encryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/base.EncryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/base.EncryptionAlgorithm} + */ +function OlmEncryption(params) { + base.EncryptionAlgorithm.call(this, params); + this._sessionPrepared = false; + this._prepPromise = null; +} +utils.inherits(OlmEncryption, base.EncryptionAlgorithm); + +/** + * @private + + * @param {string[]} roomMembers list of currently-joined users in the room + * @return {module:client.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 {module:client.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 = utils.map(members, 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).return(encryptedContent); +}; + +/** + * Olm decryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/base.DecryptionAlgorithm} + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/base.DecryptionAlgorithm} + */ +function OlmDecryption(params) { + base.DecryptionAlgorithm.call(this, params); +} +utils.inherits(OlmDecryption, base.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 base.DecryptionError( + "OLM_MISSING_CIPHERTEXT", + "Missing ciphertext", + ); + } + + if (!(this._olmDevice.deviceCurve25519Key in ciphertext)) { + throw new base.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 base.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 base.DecryptionError( + "OLM_BAD_RECIPIENT", + "Message was intented for " + payload.recipient, + ); + } + + if (payload.recipient_keys.ed25519 != this._olmDevice.deviceEd25519Key) { + throw new base.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 base.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 base.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, +) { + 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; +}; + + +base.registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/matrix-js-sdk/src/crypto/backup_password.js b/matrix-js-sdk/src/crypto/backup_password.js new file mode 100644 index 000000000..1a6d1f284 --- /dev/null +++ b/matrix-js-sdk/src/crypto/backup_password.js @@ -0,0 +1,81 @@ +/* +Copyright 2018 New Vector 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. +*/ + +import { randomString } from '../randomstring'; + +const DEFAULT_ITERATIONS = 500000; + +export async function keyForExistingBackup(backupData, password) { + if (!global.Olm) { + throw new Error("Olm is not available"); + } + + const authData = backupData.auth_data; + + if (!authData.private_key_salt || !authData.private_key_iterations) { + throw new Error( + "Salt and/or iterations not found: " + + "this backup cannot be restored with a passphrase", + ); + } + + return await deriveKey( + password, backupData.auth_data.private_key_salt, + backupData.auth_data.private_key_iterations, + ); +} + +export async function keyForNewBackup(password) { + if (!global.Olm) { + throw new Error("Olm is not available"); + } + + const salt = randomString(32); + + const key = await deriveKey(password, salt, DEFAULT_ITERATIONS); + + return { key, salt, iterations: DEFAULT_ITERATIONS }; +} + +async function deriveKey(password, salt, iterations) { + const subtleCrypto = global.crypto.subtle; + const TextEncoder = global.TextEncoder; + if (!subtleCrypto || !TextEncoder) { + // TODO: Implement this for node + throw new Error("Password-based backup is not avaiable on this platform"); + } + + const key = await subtleCrypto.importKey( + 'raw', + new TextEncoder().encode(password), + {name: 'PBKDF2'}, + false, + ['deriveBits'], + ); + + const keybits = await subtleCrypto.deriveBits( + { + name: 'PBKDF2', + salt: new TextEncoder().encode(salt), + iterations: iterations, + hash: 'SHA-512', + }, + key, + global.Olm.PRIVATE_KEY_LENGTH * 8, + ); + + return new Uint8Array(keybits); +} diff --git a/matrix-js-sdk/src/crypto/deviceinfo.js b/matrix-js-sdk/src/crypto/deviceinfo.js new file mode 100644 index 000000000..aa5c4afac --- /dev/null +++ b/matrix-js-sdk/src/crypto/deviceinfo.js @@ -0,0 +1,169 @@ +/* +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. +*/ +"use strict"; + + +/** + * @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 + */ +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 = {}; +} + +/** + * 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, + }; +}; + +/** + * 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; + +/** */ +module.exports = DeviceInfo; diff --git a/matrix-js-sdk/src/crypto/index.js b/matrix-js-sdk/src/crypto/index.js new file mode 100644 index 000000000..ceabe711b --- /dev/null +++ b/matrix-js-sdk/src/crypto/index.js @@ -0,0 +1,2384 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018-2019 New Vector 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. +*/ +"use strict"; + +/** + * @module crypto + */ + +const anotherjson = require('another-json'); +import Promise from 'bluebird'; +import {EventEmitter} from 'events'; + +import logger from '../logger'; +const utils = require("../utils"); +const OlmDevice = require("./OlmDevice"); +const olmlib = require("./olmlib"); +const algorithms = require("./algorithms"); +const DeviceInfo = require("./deviceinfo"); +const DeviceVerification = DeviceInfo.DeviceVerification; +const DeviceList = require('./DeviceList').default; +import { randomString } from '../randomstring'; + +import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager'; +import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; + +import {ShowQRCode, ScanQRCode} from './verification/QRCode'; +import SAS from './verification/SAS'; +import { + newUserCancelledError, + newUnexpectedMessageError, + newUnknownMethodError, +} from './verification/Error'; + +const defaultVerificationMethods = { + [ScanQRCode.NAME]: ScanQRCode, + [ShowQRCode.NAME]: ShowQRCode, + [SAS.NAME]: SAS, +}; + +/** + * verification method names + */ +export const verificationMethods = { + QR_CODE_SCAN: ScanQRCode.NAME, + QR_CODE_SHOW: ShowQRCode.NAME, + SAS: SAS.NAME, +}; + +export function isCryptoAvailable() { + return Boolean(global.Olm); +} + +const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; +const KEY_BACKUP_KEYS_PER_REQUEST = 200; + +/** + * Cryptography bits + * + * This module is internal to the js-sdk; the public API is via MatrixClient. + * + * @constructor + * @alias module:crypto + * + * @internal + * + * @param {module:base-apis~MatrixBaseApis} 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 default function Crypto(baseApis, sessionStore, userId, deviceId, + clientStore, cryptoStore, roomList, verificationMethods) { + this._baseApis = baseApis; + this._sessionStore = sessionStore; + this._userId = userId; + this._deviceId = deviceId; + this._clientStore = clientStore; + this._cryptoStore = cryptoStore; + this._roomList = roomList; + this._verificationMethods = new Map(); + if (verificationMethods) { + 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, + ); + } + } + } + + // track whether this device's megolm keys are being backed up incrementally + // to the server or not. + // XXX: this should probably have a single source of truth from OlmAccount + this.backupInfo = null; // The info dict from /room_keys/version + this.backupKey = null; // The encryption key object + this._checkedForBackup = false; // Have we checked the server for a backup we can use? + this._sendingBackups = false; // Are we currently sending backups? + + this._olmDevice = new OlmDevice(cryptoStore); + this._deviceList = new DeviceList( + baseApis, cryptoStore, this._olmDevice, + ); + + // 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 = new Map(); + + // map from algorithm to DecryptionAlgorithm instance, for each room + this._roomDecryptors = new Map(); + + this._supportedAlgorithms = Array.from(algorithms.DECRYPTION_CLASSES.keys()); + + this._deviceKeys = {}; + + this._globalBlacklistUnverifiedDevices = false; + + 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._verificationTransactions = new Map(); +} +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. + */ +Crypto.prototype.init = async function() { + logger.log("Crypto: initialising Olm..."); + await global.Olm.init(); + logger.log("Crypto: initialising Olm device..."); + await this._olmDevice.init(); + 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(); + } + + logger.log("Crypto: checking for key backup..."); + this._checkAndStartKeyBackup(); +}; + +/** + * Check the server for an active key backup and + * if one is present and has a valid signature from + * one of the user's verified devices, start backing up + * to it. + */ +Crypto.prototype._checkAndStartKeyBackup = async function() { + logger.log("Checking key backup status..."); + if (this._baseApis.isGuest()) { + logger.log("Skipping key backup check since user is guest"); + this._checkedForBackup = true; + return null; + } + let backupInfo; + try { + backupInfo = await this._baseApis.getKeyBackupVersion(); + } catch (e) { + logger.log("Error checking for active key backup", e); + if (e.httpStatus / 100 === 4) { + // well that's told us. we won't try again. + this._checkedForBackup = true; + } + return null; + } + this._checkedForBackup = true; + + const trustInfo = await this.isKeyBackupTrusted(backupInfo); + + if (trustInfo.usable && !this.backupInfo) { + logger.log( + "Found usable key backup v" + backupInfo.version + + ": enabling key backups", + ); + this._baseApis.enableKeyBackup(backupInfo); + } else if (!trustInfo.usable && this.backupInfo) { + logger.log("No usable key backup: disabling key backup"); + this._baseApis.disableKeyBackup(); + } else if (!trustInfo.usable && !this.backupInfo) { + logger.log("No usable key backup: not enabling key backup"); + } else if (trustInfo.usable && this.backupInfo) { + // may not be the same version: if not, we should switch + if (backupInfo.version !== this.backupInfo.version) { + logger.log( + "On backup version " + this.backupInfo.version + " but found " + + "version " + backupInfo.version + ": switching.", + ); + this._baseApis.disableKeyBackup(); + this._baseApis.enableKeyBackup(backupInfo); + } else { + logger.log("Backup version " + backupInfo.version + " still current"); + } + } + + return {backupInfo, trustInfo}; +}; + +Crypto.prototype.setTrustedBackupPubKey = async function(trustedPubKey) { + // This should be redundant post cross-signing is a thing, so just + // plonk it in localStorage for now. + this._sessionStore.setLocalTrustedBackupPubKey(trustedPubKey); + await this.checkKeyBackup(); +}; + +/** + * Forces a re-check of the key backup and enables/disables it + * as appropriate. + * + * @return {Object} Object with backup info (as returned by + * getKeyBackupVersion) in backupInfo and + * trust information (as returned by isKeyBackupTrusted) + * in trustInfo. + */ +Crypto.prototype.checkKeyBackup = async function() { + this._checkedForBackup = false; + const returnInfo = await this._checkAndStartKeyBackup(); + return returnInfo; +}; + +/** + * @param {object} backupInfo key backup info dict from /room_keys/version + * @return {object} { + * usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device + * sigs: [ + * valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation + * deviceId: [string], + * device: [DeviceInfo || null], + * ] + * } + */ +Crypto.prototype.isKeyBackupTrusted = async function(backupInfo) { + const ret = { + usable: false, + trusted_locally: false, + sigs: [], + }; + + if ( + !backupInfo || + !backupInfo.algorithm || + !backupInfo.auth_data || + !backupInfo.auth_data.public_key || + !backupInfo.auth_data.signatures + ) { + logger.info("Key backup is absent or missing required data"); + return ret; + } + + const trustedPubkey = this._sessionStore.getLocalTrustedBackupPubKey(); + + if (backupInfo.auth_data.public_key === trustedPubkey) { + logger.info("Backup public key " + trustedPubkey + " is trusted locally"); + ret.trusted_locally = true; + } + + const mySigs = backupInfo.auth_data.signatures[this._userId] || []; + + for (const keyId of Object.keys(mySigs)) { + const keyIdParts = keyId.split(':'); + if (keyIdParts[0] !== 'ed25519') { + logger.log("Ignoring unknown signature type: " + keyIdParts[0]); + continue; + } + const sigInfo = { deviceId: keyIdParts[1] }; // XXX: is this how we're supposed to get the device ID? + const device = this._deviceList.getStoredDevice( + this._userId, sigInfo.deviceId, + ); + if (device) { + sigInfo.device = device; + try { + await olmlib.verifySignature( + this._olmDevice, + // verifySignature modifies the object so we need to copy + // if we verify more than one sig + Object.assign({}, backupInfo.auth_data), + this._userId, + device.deviceId, + device.getFingerprint(), + ); + sigInfo.valid = true; + } catch (e) { + logger.info( + "Bad signature from key ID " + keyId + " userID " + this._userId + + " device ID " + device.deviceId + " fingerprint: " + + device.getFingerprint(), backupInfo.auth_data, e, + ); + sigInfo.valid = false; + } + } else { + sigInfo.valid = null; // Can't determine validity because we don't have the signing device + logger.info("Ignoring signature from unknown key " + keyId); + } + ret.sigs.push(sigInfo); + } + + ret.usable = ( + ret.sigs.some((s) => s.valid && s.device.isVerified()) || + ret.trusted_locally + ); + return ret; +}; + +/** + */ +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", function(event) { + crypto._onToDeviceEvent(event); + }); +}; + + +/** 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(); +}; + +/** + * @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; +}; + +/** + * 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; +}; + +/** + * 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(() => { + crypto._baseApis.uploadKeysRequest({ + device_keys: deviceKeys, + }, { + // for now, we set the device id explicitly, as we may not be using the + // same one as used in login. + device_id: deviceId, + }); + }); +}; + +/** + * 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"); + } +}; + +// 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); + + function uploadLoop(keyCount) { + if (keyLimit <= keyCount) { + // If we don't need to generate any more keys then we are done. + return Promise.resolve(); + } + + const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle); + + // Ask olm to generate new one time keys, then upload them to synapse. + return crypto._olmDevice.generateOneTimeKeys(keysThisLoop).then(() => { + return _uploadOneTimeKeys(crypto); + }).then((res) => { + 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 + return uploadLoop(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({}, { + device_id: crypto._deviceId, + }).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; + }).done(); +} + +// returns a promise which resolves to the response +async function _uploadOneTimeKeys(crypto) { + const oneTimeKeys = await crypto._olmDevice.getOneTimeKeys(); + const oneTimeJson = {}; + + const promises = []; + + 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, + }, { + // for now, we set the device id explicitly, as we may not be using the + // same one as used in login. + device_id: crypto._deviceId, + }); + + 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 + * + * @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, +) { + 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 && known !== undefined) { + knownStatus = known; + } + + if (dev.verified !== verificationStatus || dev.known !== knownStatus) { + dev.verified = verificationStatus; + dev.known = knownStatus; + this._deviceList.storeDevicesForUser(userId, devices); + this._deviceList.saveIfDirty(); + } + return DeviceInfo.fromStorage(dev, deviceId); +}; + + +Crypto.prototype.requestVerification = function(userId, methods, devices) { + if (!methods) { + // .keys() returns an iterator, so we need to explicitly turn it into an array + methods = [...this._verificationMethods.keys()]; + } + if (!devices) { + devices = Object.keys(this._deviceList.getRawStoredDevicesForUser(userId)); + } + if (!this._verificationTransactions.has(userId)) { + this._verificationTransactions.set(userId, new Map); + } + + const transactionId = randomString(32); + + const promise = new Promise((resolve, reject) => { + this._verificationTransactions.get(userId).set(transactionId, { + request: { + methods: methods, + devices: devices, + resolve: resolve, + reject: reject, + }, + }); + }); + + const message = { + transaction_id: transactionId, + from_device: this._baseApis.deviceId, + methods: methods, + timestamp: Date.now(), + }; + const msgMap = {}; + for (const deviceId of devices) { + msgMap[deviceId] = message; + } + this._baseApis.sendToDevice("m.key.verification.request", {[userId]: msgMap}); + + return promise; +}; + +Crypto.prototype.beginKeyVerification = function( + method, userId, deviceId, transactionId, +) { + if (!this._verificationTransactions.has(userId)) { + this._verificationTransactions.set(userId, new Map()); + } + transactionId = transactionId || randomString(32); + if (method instanceof Array) { + if (method.length !== 2 + || !this._verificationMethods.has(method[0]) + || !this._verificationMethods.has(method[1])) { + throw newUnknownMethodError(); + } + /* + return new TwoPartVerification( + this._verificationMethods[method[0]], + this._verificationMethods[method[1]], + userId, deviceId, transactionId, + ); + */ + } else if (this._verificationMethods.has(method)) { + const verifier = new (this._verificationMethods.get(method))( + this._baseApis, userId, deviceId, transactionId, + ); + if (!this._verificationTransactions.get(userId).has(transactionId)) { + this._verificationTransactions.get(userId).set(transactionId, {}); + } + this._verificationTransactions.get(userId).get(transactionId).verifier = verifier; + return verifier; + } else { + throw newUnknownMethodError(); + } +}; + + +/** + * 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 this event from somewhere else + // TODO: check if we can trust the forwarders. + 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; +}; + +/** + * 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.get(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) { + // 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.get(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.get(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.set(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 riot 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.has(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; + } + 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 {module:client.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 + * @return {module:client.Promise} a promise which resolves once the keys have been imported + */ +Crypto.prototype.importRoomKeys = function(keys) { + return Promise.map( + keys, (key) => { + if (!key.room_id || !key.algorithm) { + logger.warn("ignoring room key entry with missing fields", key); + return null; + } + + const alg = this._getRoomDecryptor(key.room_id, key.algorithm); + return alg.importRoomKey(key); + }, + ); +}; + +/** + * Schedules sending all keys waiting to be sent to the backup, if not already + * scheduled. Retries if necessary. + * + * @param {number} maxDelay Maximum delay to wait in ms. 0 means no delay. + */ +Crypto.prototype.scheduleKeyBackupSend = async function(maxDelay = 10000) { + if (this._sendingBackups) return; + + this._sendingBackups = true; + + try { + // wait between 0 and `maxDelay` seconds, to avoid backup + // requests from different clients hitting the server all at + // the same time when a new key is sent + const delay = Math.random() * maxDelay; + await Promise.delay(delay); + let numFailures = 0; // number of consecutive failures + while (1) { + if (!this.backupKey) { + return; + } + try { + const numBackedUp = + await this._backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST); + if (numBackedUp === 0) { + // no sessions left needing backup: we're done + return; + } + numFailures = 0; + } catch (err) { + numFailures++; + logger.log("Key backup request failed", err); + if (err.data) { + if ( + err.data.errcode == 'M_NOT_FOUND' || + err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION' + ) { + // Re-check key backup status on error, so we can be + // sure to present the current situation when asked. + await this.checkKeyBackup(); + // Backup version has changed or this backup version + // has been deleted + this.emit("crypto.keyBackupFailed", err.data.errcode); + throw err; + } + } + } + if (numFailures) { + // exponential backoff if we have failures + await Promise.delay(1000 * Math.pow(2, Math.min(numFailures - 1, 4))); + } + } + } finally { + this._sendingBackups = false; + } +}; + +/** + * Take some e2e keys waiting to be backed up and send them + * to the backup. + * + * @param {integer} limit Maximum number of keys to back up + * @returns {integer} Number of sessions backed up + */ +Crypto.prototype._backupPendingKeys = async function(limit) { + const sessions = await this._cryptoStore.getSessionsNeedingBackup(limit); + if (!sessions.length) { + return 0; + } + + let remaining = await this._cryptoStore.countSessionsNeedingBackup(); + this.emit("crypto.keyBackupSessionsRemaining", remaining); + + const data = {}; + for (const session of sessions) { + const roomId = session.sessionData.room_id; + if (data[roomId] === undefined) { + data[roomId] = {sessions: {}}; + } + + const sessionData = await this._olmDevice.exportInboundGroupSession( + session.senderKey, session.sessionId, session.sessionData, + ); + sessionData.algorithm = olmlib.MEGOLM_ALGORITHM; + delete sessionData.session_id; + delete sessionData.room_id; + const firstKnownIndex = sessionData.first_known_index; + delete sessionData.first_known_index; + const encrypted = this.backupKey.encrypt(JSON.stringify(sessionData)); + + const forwardedCount = + (sessionData.forwarding_curve25519_key_chain || []).length; + + const device = this._deviceList.getDeviceByIdentityKey( + olmlib.MEGOLM_ALGORITHM, session.senderKey, + ); + + data[roomId]['sessions'][session.sessionId] = { + first_message_index: firstKnownIndex, + forwarded_count: forwardedCount, + is_verified: !!(device && device.isVerified()), + session_data: encrypted, + }; + } + + await this._baseApis.sendKeyBackup( + undefined, undefined, this.backupInfo.version, + {rooms: data}, + ); + + await this._cryptoStore.unmarkSessionsNeedingBackup(sessions); + remaining = await this._cryptoStore.countSessionsNeedingBackup(); + this.emit("crypto.keyBackupSessionsRemaining", remaining); + + return sessions.length; +}; + +Crypto.prototype.backupGroupSession = async function( + roomId, senderKey, forwardingCurve25519KeyChain, + sessionId, sessionKey, keysClaimed, + exportFormat, +) { + if (!this.backupInfo) { + throw new Error("Key backups are not enabled"); + } + + await this._cryptoStore.markSessionsNeedingBackup([{ + senderKey: senderKey, + sessionId: sessionId, + }]); + + // don't wait for this to complete: it will delay so + // happens in the background + this.scheduleKeyBackupSend(); +}; + +/** + * Marks all group sessions as needing to be backed up and schedules them to + * upload in the background as soon as possible. + */ +Crypto.prototype.scheduleAllGroupSessionsForBackup = async function() { + await this.flagAllGroupSessionsForBackup(); + + // Schedule keys to upload in the background as soon as possible. + this.scheduleKeyBackupSend(0 /* maxDelay */); +}; + +/** + * Marks all group sessions as needing to be backed up without scheduling + * them to upload in the background. + * @returns {Promise} Resolves to the number of sessions requiring a backup. + */ +Crypto.prototype.flagAllGroupSessionsForBackup = async function() { + await this._cryptoStore.doTxn( + 'readwrite', + [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_BACKUP, + ], + (txn) => { + this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => { + if (session !== null) { + this._cryptoStore.markSessionsNeedingBackup([session], txn); + } + }); + }, + ); + + const remaining = await this._cryptoStore.countSessionsNeedingBackup(); + this.emit("crypto.keyBackupSessionsRemaining", remaining); + return remaining; +}; + +/* 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 {module:client.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.get(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 = function(event) { + if (event.isRedacted()) { + return Promise.resolve({ + clearEvent: { + room_id: event.getRoomId(), + type: "m.room.message", + content: {}, + }, + }); + } + const content = event.getWireContent(); + const alg = this._getRoomDecryptor(event.getRoomId(), content.algorithm); + return 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.sendRoomKeyRequest( + requestBody, recipients, resend, + ).catch((e) => { + // this normally means we couldn't talk to the store + logger.error( + 'Error requesting key for event', e, + ); + }).done(); +}; + +/** + * 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); + }).done(); +}; + +/** + * 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(); + this._roomDeviceTrackingState = {}; + } +}; + +/** + * 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/riot-web/issues/2782). + if (!syncData.catchingUp) { + _maybeUploadOneTimeKeys(this); + this._processReceivedRoomKeyRequests(); + } +}; + +/** + * 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.get(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 { + 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.key.verification.request") { + this._onKeyVerificationRequest(event); + } else if (event.getType() === "m.key.verification.start") { + this._onKeyVerificationStart(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()) { + // 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._checkedForBackup) { + // don't bother awaiting on this - the important thing is that we retry if we + // haven't managed to check before + this._checkAndStartKeyBackup(); + } + + const alg = this._getRoomDecryptor(content.room_id, content.algorithm); + alg.onRoomKeyEvent(event); +}; + +/** + * Handle a key verification request event. + * + * @private + * @param {module:models/event.MatrixEvent} event verification request event + */ +Crypto.prototype._onKeyVerificationRequest = function(event) { + if (event.isCancelled()) { + logger.warn("Ignoring flagged verification request from " + event.getSender()); + return; + } + + const content = event.getContent(); + if (!("from_device" in content) || typeof content.from_device !== "string" + || !("transaction_id" in content) || typeof content.from_device !== "string" + || !("methods" in content) || !(content.methods instanceof Array) + || !("timestamp" in content) || typeof content.timestamp !== "number") { + logger.warn("received invalid verification request from " + event.getSender()); + // ignore event if malformed + return; + } + + const now = Date.now(); + if (now < content.timestamp - (5 * 60 * 1000) + || now > content.timestamp + (10 * 60 * 1000)) { + // ignore if event is too far in the past or too far in the future + logger.log("received verification that is too old or from the future"); + return; + } + + const sender = event.getSender(); + if (sender === this._userId && content.from_device === this._deviceId) { + // ignore requests from ourselves, because it doesn't make sense for a + // device to verify itself + return; + } + if (this._verificationTransactions.has(sender)) { + if (this._verificationTransactions.get(sender).has(content.transaction_id)) { + // transaction already exists: cancel it and drop the existing + // request because someone has gotten confused + const err = newUnexpectedMessageError({ + transaction_id: content.transaction_id, + }); + if (this._verificationTransactions.get(sender).get(content.transaction_id) + .verifier) { + this._verificationTransactions.get(sender).get(content.transaction_id) + .verifier.cancel(err); + } else { + this._verificationTransactions.get(sender).get(content.transaction_id) + .reject(err); + this.sendToDevice("m.key.verification.cancel", { + [sender]: { + [content.from_device]: err.getContent(), + }, + }); + } + this._verificationTransactions.get(sender).delete(content.transaction_id); + return; + } + } else { + this._verificationTransactions.set(sender, new Map()); + } + + // determine what requested methods we support + const methods = []; + for (const method of content.methods) { + if (typeof method !== "string") { + continue; + } + if (this._verificationMethods.has(method)) { + methods.push(method); + } + } + if (methods.length === 0) { + this._baseApis.emit( + "crypto.verification.request.unknown", + event.getSender(), + () => { + this.sendToDevice("m.key.verification.cancel", { + [sender]: { + [content.from_device]: newUserCancelledError({ + transaction_id: content.transaction_id, + }).getContent(), + }, + }); + }, + ); + } else { + // notify the application of the verification request, so it can + // decide what to do with it + const request = { + event: event, + methods: methods, + beginKeyVerification: (method) => { + const verifier = this.beginKeyVerification( + method, + sender, + content.from_device, + content.transaction_id, + ); + this._verificationTransactions.get(sender).get(content.transaction_id) + .verifier = verifier; + return verifier; + }, + cancel: () => { + this._baseApis.sendToDevice("m.key.verification.cancel", { + [sender]: { + [content.from_device]: newUserCancelledError({ + transaction_id: content.transaction_id, + }).getContent(), + }, + }); + }, + }; + this._verificationTransactions.get(sender).set(content.transaction_id, { + request: request, + }); + this._baseApis.emit("crypto.verification.request", request); + } +}; + +/** + * Handle a key verification start event. + * + * @private + * @param {module:models/event.MatrixEvent} event verification start event + */ +Crypto.prototype._onKeyVerificationStart = function(event) { + if (event.isCancelled()) { + logger.warn("Ignoring flagged verification start from " + event.getSender()); + return; + } + + const sender = event.getSender(); + const content = event.getContent(); + const transactionId = content.transaction_id; + const deviceId = content.from_device; + if (!transactionId || !deviceId) { + // invalid request, and we don't have enough information to send a + // cancellation, so just ignore it + return; + } + + let handler = this._verificationTransactions.has(sender) + && this._verificationTransactions.get(sender).get(transactionId); + // if the verification start message is invalid, send a cancel message to + // the other side, and also send a cancellation event + const cancel = (err) => { + if (handler.verifier) { + handler.verifier.cancel(err); + } else if (handler.request && handler.request.cancel) { + handler.request.cancel(err); + } + this.sendToDevice( + "m.key.verification.cancel", { + [sender]: { + [deviceId]: err.getContent(), + }, + }, + ); + }; + if (!this._verificationMethods.has(content.method)) { + cancel(newUnknownMethodError({ + transaction_id: content.transactionId, + })); + return; + } else if (content.next_method) { + if (!this._verificationMethods.has(content.next_method)) { + cancel(newUnknownMethodError({ + transaction_id: content.transactionId, + })); + return; + } else { + /* TODO: + const verification = new TwoPartVerification( + this._verificationMethods[content.method], + this._verificationMethods[content.next_method], + userId, deviceId, + ); + this.emit(verification.event_type, verification); + this.emit(verification.first.event_type, verification);*/ + } + } else { + const verifier = new (this._verificationMethods.get(content.method))( + this._baseApis, sender, deviceId, content.transaction_id, + event, handler && handler.request, + ); + if (!handler) { + if (!this._verificationTransactions.has(sender)) { + this._verificationTransactions.set(sender, new Map()); + } + handler = this._verificationTransactions.get(sender).set(transactionId, { + verifier: verifier, + }); + } else { + if (!handler.verifier) { + handler.verifier = verifier; + if (handler.request) { + // the verification start was sent as a response to a + // verification request + + if (!handler.request.devices.includes(deviceId)) { + // didn't send a request to that device, so it + // shouldn't have responded + cancel(newUnexpectedMessageError({ + transaction_id: content.transactionId, + })); + return; + } + if (!handler.request.methods.includes(content.method)) { + // verification method wasn't one that was requested + cancel(newUnknownMethodError({ + transaction_id: content.transactionId, + })); + return; + } + + // send cancellation messages to all the other devices that + // the request was sent to + const message = { + transaction_id: transactionId, + code: "m.accepted", + reason: "Verification request accepted by another device", + }; + const msgMap = {}; + for (const devId of handler.request.devices) { + if (devId !== deviceId) { + msgMap[devId] = message; + } + } + this._baseApis.sendToDevice("m.key.verification.cancel", { + [sender]: msgMap, + }); + + handler.request.resolve(verifier); + } + } else { + // FIXME: make sure we're in a two-part verification, and the start matches the second part + } + } + this._baseApis.emit("crypto.verification.start", verifier); + } +}; + +/** + * Handle a general key verification event. + * + * @private + * @param {module:models/event.MatrixEvent} event verification start event + */ +Crypto.prototype._onKeyVerificationMessage = function(event) { + const sender = event.getSender(); + const transactionId = event.getContent().transaction_id; + const handler = this._verificationTransactions.has(sender) + && this._verificationTransactions.get(sender).get(transactionId); + if (!handler) { + return; + } else if (event.getType() === "m.key.verification.cancel") { + logger.log(event); + if (handler.verifier) { + handler.verifier.cancel(event); + } else if (handler.request && handler.request.cancel) { + handler.request.cancel(event); + } + } else if (handler.verifier) { + const verifier = handler.verifier; + if (verifier.events + && verifier.events.includes(event.getType())) { + verifier.handleEvent(event); + } + } +}; + +/** + * 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; + + 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", + ); + 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? + const device = this._deviceList.getDeviceByIdentityKey(algorithm, deviceKey); + if (!device) { + logger.info( + "Couldn't find device for identity key " + deviceKey + + ": not re-establishing session", + ); + 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._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.get(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.map( + requests, (req) => + this._processReceivedRoomKeyRequest(req), + ); + await Promise.map( + cancellations, (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.has(roomId)) { + logger.debug(`room key request for unencrypted room ${roomId}`); + return; + } + const encryptor = this._roomEncryptors.get(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; + } + + // 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.has(roomId)) { + logger.log(`room key request for unencrypted room ${roomId}`); + return; + } + + const decryptor = this._roomDecryptors.get(roomId).get(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 is verified already, share the keys + const device = this._deviceList.getStoredDevice(userId, deviceId); + if (device && device.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.get(roomId); + if (!decryptors) { + this._roomDecryptors[roomId] = decryptors = new Map(); + } + + alg = decryptors.get(algorithm); + if (alg) { + return alg; + } + } + + const AlgClass = algorithms.DECRYPTION_CLASSES.get(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.set(algorithm, alg); + } + return alg; +}; + + +/** + * 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 = {}; + sigs[this._userId] = {}; + sigs[this._userId]["ed25519:" + this._deviceId] = + await this._olmDevice.sign(anotherjson.stringify(obj)); + obj.signatures = sigs; +}; + + +/** + * 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/matrix-js-sdk/src/crypto/olmlib.js b/matrix-js-sdk/src/crypto/olmlib.js new file mode 100644 index 000000000..5491d3c5a --- /dev/null +++ b/matrix-js-sdk/src/crypto/olmlib.js @@ -0,0 +1,338 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2019 New Vector 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. +*/ + +/** + * @module olmlib + * + * Utilities common to olm encryption algorithms + */ + +import Promise from 'bluebird'; +const anotherjson = require('another-json'); + +import logger from '../logger'; +const utils = require("../utils"); + +/** + * matrix algorithm tag for olm + */ +module.exports.OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; + +/** + * matrix algorithm tag for megolm + */ +module.exports.MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; + +/** + * matrix algorithm tag for megolm backups + */ +module.exports.MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2"; + + +/** + * Encrypt an event payload for an Olm device + * + * @param {Object} resultsObject The `ciphertext` property + * of the m.room.encrypted event to which to add our result + * + * @param {string} ourUserId + * @param {string} ourDeviceId + * @param {module:crypto/OlmDevice} olmDevice olm.js wrapper + * @param {string} recipientUserId + * @param {module:crypto/deviceinfo} recipientDevice + * @param {object} payloadFields fields to include in the encrypted payload + * + * Returns a promise which resolves (to undefined) when the payload + * has been encrypted into `resultsObject` + */ +module.exports.encryptMessageForDevice = async function( + resultsObject, + ourUserId, ourDeviceId, olmDevice, recipientUserId, recipientDevice, + payloadFields, +) { + const deviceKey = recipientDevice.getIdentityKey(); + const sessionId = await olmDevice.getSessionIdForDevice(deviceKey); + if (sessionId === null) { + // If we don't have a session for a device then + // we can't encrypt a message for it. + return; + } + + logger.log( + "Using sessionid " + sessionId + " for device " + + recipientUserId + ":" + recipientDevice.deviceId, + ); + + const payload = { + sender: ourUserId, + sender_device: ourDeviceId, + + // Include the Ed25519 key so that the recipient knows what + // device this message came from. + // We don't need to include the curve25519 key since the + // recipient will already know this from the olm headers. + // When combined with the device keys retrieved from the + // homeserver signed by the ed25519 key this proves that + // the curve25519 key and the ed25519 key are owned by + // the same device. + keys: { + "ed25519": olmDevice.deviceEd25519Key, + }, + + // include the recipient device details in the payload, + // to avoid unknown key attacks, per + // https://github.com/vector-im/vector-web/issues/2483 + recipient: recipientUserId, + recipient_keys: { + "ed25519": recipientDevice.getFingerprint(), + }, + }; + + // TODO: technically, a bunch of that stuff only needs to be included for + // pre-key messages: after that, both sides know exactly which devices are + // involved in the session. If we're looking to reduce data transfer in the + // future, we could elide them for subsequent messages. + + utils.extend(payload, payloadFields); + + resultsObject[deviceKey] = await olmDevice.encryptMessage( + deviceKey, sessionId, JSON.stringify(payload), + ); +}; + +/** + * Try to make sure we have established olm sessions for the given devices. + * + * @param {module:crypto/OlmDevice} olmDevice + * + * @param {module:base-apis~MatrixBaseApis} baseApis + * + * @param {object} devicesByUser + * map from userid to list of devices to ensure sessions for + * + * @param {bolean} force If true, establish a new session even if one already exists. + * Optional. + * + * @return {module:client.Promise} resolves once the sessions are complete, to + * an Object mapping from userId to deviceId to + * {@link module:crypto~OlmSessionResult} + */ +module.exports.ensureOlmSessionsForDevices = async function( + olmDevice, baseApis, devicesByUser, force, +) { + const devicesWithoutSession = [ + // [userId, deviceId], ... + ]; + const result = {}; + const resolveSession = {}; + + for (const userId in devicesByUser) { + if (!devicesByUser.hasOwnProperty(userId)) { + continue; + } + result[userId] = {}; + const devices = devicesByUser[userId]; + for (let j = 0; j < devices.length; j++) { + const deviceInfo = devices[j]; + const deviceId = deviceInfo.deviceId; + const key = deviceInfo.getIdentityKey(); + if (!olmDevice._sessionsInProgress[key]) { + // pre-emptively mark the session as in-progress to avoid race + // conditions. If we find that we already have a session, then + // we'll resolve + olmDevice._sessionsInProgress[key] = new Promise( + (resolve, reject) => { + resolveSession[key] = { + resolve: (...args) => { + delete olmDevice._sessionsInProgress[key]; + resolve(...args); + }, + reject: (...args) => { + delete olmDevice._sessionsInProgress[key]; + reject(...args); + }, + }; + }, + ); + } + const sessionId = await olmDevice.getSessionIdForDevice( + key, resolveSession[key], + ); + if (sessionId !== null && resolveSession[key]) { + // we found a session, but we had marked the session as + // in-progress, so unmark it and unblock anything that was + // waiting + delete olmDevice._sessionsInProgress[key]; + resolveSession[key].resolve(); + delete resolveSession[key]; + } + if (sessionId === null || force) { + devicesWithoutSession.push([userId, deviceId]); + } + result[userId][deviceId] = { + device: deviceInfo, + sessionId: sessionId, + }; + } + } + + if (devicesWithoutSession.length === 0) { + return result; + } + + const oneTimeKeyAlgorithm = "signed_curve25519"; + let res; + try { + res = await baseApis.claimOneTimeKeys( + devicesWithoutSession, oneTimeKeyAlgorithm, + ); + } catch (e) { + for (const resolver of Object.values(resolveSession)) { + resolver.resolve(); + } + logger.log("failed to claim one-time keys", e, devicesWithoutSession); + throw e; + } + + const otk_res = res.one_time_keys || {}; + const promises = []; + for (const userId in devicesByUser) { + if (!devicesByUser.hasOwnProperty(userId)) { + continue; + } + const userRes = otk_res[userId] || {}; + const devices = devicesByUser[userId]; + for (let j = 0; j < devices.length; j++) { + const deviceInfo = devices[j]; + const deviceId = deviceInfo.deviceId; + const key = deviceInfo.getIdentityKey(); + if (result[userId][deviceId].sessionId && !force) { + // we already have a result for this device + continue; + } + + const deviceRes = userRes[deviceId] || {}; + let oneTimeKey = null; + for (const keyId in deviceRes) { + if (keyId.indexOf(oneTimeKeyAlgorithm + ":") === 0) { + oneTimeKey = deviceRes[keyId]; + } + } + + if (!oneTimeKey) { + const msg = "No one-time keys (alg=" + oneTimeKeyAlgorithm + + ") for device " + userId + ":" + deviceId; + logger.warn(msg); + if (resolveSession[key]) { + resolveSession[key].resolve(); + } + continue; + } + + promises.push( + _verifyKeyAndStartSession( + olmDevice, oneTimeKey, userId, deviceInfo, + ).then((sid) => { + if (resolveSession[key]) { + resolveSession[key].resolve(sid); + } + result[userId][deviceId].sessionId = sid; + }, (e) => { + if (resolveSession[key]) { + resolveSession[key].resolve(); + } + throw e; + }), + ); + } + } + + await Promise.all(promises); + return result; +}; + +async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo) { + const deviceId = deviceInfo.deviceId; + try { + await _verifySignature( + olmDevice, oneTimeKey, userId, deviceId, + deviceInfo.getFingerprint(), + ); + } catch (e) { + logger.error( + "Unable to verify signature on one-time key for device " + + userId + ":" + deviceId + ":", e, + ); + return null; + } + + let sid; + try { + sid = await olmDevice.createOutboundSession( + deviceInfo.getIdentityKey(), oneTimeKey.key, + ); + } catch (e) { + // possibly a bad key + logger.error("Error starting session with device " + + userId + ":" + deviceId + ": " + e); + return null; + } + + logger.log("Started new sessionid " + sid + + " for device " + userId + ":" + deviceId); + return sid; +} + + +/** + * Verify the signature on an object + * + * @param {module:crypto/OlmDevice} olmDevice olm wrapper to use for verify op + * + * @param {Object} obj object to check signature on. Note that this will be + * stripped of its 'signatures' and 'unsigned' properties. + * + * @param {string} signingUserId ID of the user whose signature should be checked + * + * @param {string} signingDeviceId ID of the device whose signature should be checked + * + * @param {string} signingKey base64-ed ed25519 public key + * + * Returns a promise which resolves (to undefined) if the the signature is good, + * or rejects with an Error if it is bad. + */ +const _verifySignature = module.exports.verifySignature = async function( + olmDevice, obj, signingUserId, signingDeviceId, signingKey, +) { + const signKeyId = "ed25519:" + signingDeviceId; + const signatures = obj.signatures || {}; + const userSigs = signatures[signingUserId] || {}; + const signature = userSigs[signKeyId]; + if (!signature) { + throw Error("No signature"); + } + + // prepare the canonical json: remove unsigned and signatures, and stringify with + // anotherjson + delete obj.unsigned; + delete obj.signatures; + const json = anotherjson.stringify(obj); + + olmDevice.verifySignature( + signingKey, json, signature, + ); +}; diff --git a/matrix-js-sdk/src/crypto/recoverykey.js b/matrix-js-sdk/src/crypto/recoverykey.js new file mode 100644 index 000000000..198521c7d --- /dev/null +++ b/matrix-js-sdk/src/crypto/recoverykey.js @@ -0,0 +1,66 @@ +/* +Copyright 2018 New Vector 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. +*/ + +import bs58 from 'bs58'; + +// picked arbitrarily but to try & avoid clashing with any bitcoin ones +// (which are also base58 encoded, but bitcoin's involve a lot more hashing) +const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; + +export function encodeRecoveryKey(key) { + 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); + + let parity = 0; + for (let i = 0; i < buf.length - 1; ++i) { + parity ^= buf[i]; + } + buf[buf.length - 1] = parity; + const base58key = bs58.encode(buf); + + return base58key.match(/.{1,4}/g).join(" "); +} + +export function decodeRecoveryKey(recoverykey) { + const result = bs58.decode(recoverykey.replace(/ /g, '')); + + let parity = 0; + for (const b of result) { + parity ^= b; + } + if (parity !== 0) { + throw new Error("Incorrect parity"); + } + + for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) { + if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) { + throw new Error("Incorrect prefix"); + } + } + + if ( + result.length !== + OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1 + ) { + throw new Error("Incorrect length"); + } + + return result.slice( + OLM_RECOVERY_KEY_PREFIX.length, + OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH, + ); +} diff --git a/matrix-js-sdk/src/crypto/store/base.js b/matrix-js-sdk/src/crypto/store/base.js new file mode 100644 index 000000000..d9d1f7a94 --- /dev/null +++ b/matrix-js-sdk/src/crypto/store/base.js @@ -0,0 +1,34 @@ +/** + * Internal module. Defintions for storage for the crypto module + * + * @module + */ + +/** + * Abstraction of things that can store data required for end-to-end encryption + * + * @interface CryptoStore + */ + +/** + * Represents an outgoing room key request + * + * @typedef {Object} OutgoingRoomKeyRequest + * + * @property {string} requestId unique id for this request. Used for both + * an id within the request for later pairing with a cancellation, and for + * the transaction id when sending the to_device messages to our local + * server. + * + * @property {string?} cancellationTxnId + * transaction id for the cancellation, if any + * + * @property {Array<{userId: string, deviceId: string}>} recipients + * list of recipients for the request + * + * @property {module:crypto~RoomKeyRequestBody} requestBody + * parameters for the request. + * + * @property {Number} state current state of this request (states are defined + * in {@link module:crypto/OutgoingRoomKeyRequestManager~ROOM_KEY_REQUEST_STATES}) + */ diff --git a/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.js b/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.js new file mode 100644 index 000000000..20828d2e8 --- /dev/null +++ b/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.js @@ -0,0 +1,710 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ + +import Promise from 'bluebird'; + +import logger from '../../logger'; +import utils from '../../utils'; + +export const VERSION = 7; + +/** + * Implementation of a CryptoStore which is backed by an existing + * IndexedDB connection. Generally you want IndexedDBCryptoStore + * which connects to the database and defers to one of these. + * + * @implements {module:crypto/store/base~CryptoStore} + */ +export class Backend { + /** + * @param {IDBDatabase} db + */ + constructor(db) { + this._db = db; + + // make sure we close the db on `onversionchange` - otherwise + // attempts to delete the database will block (and subsequent + // attempts to re-create it will also block). + db.onversionchange = (ev) => { + logger.log(`versionchange for indexeddb ${this._dbName}: closing`); + db.close(); + }; + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + getOrAddOutgoingRoomKeyRequest(request) { + const requestBody = request.requestBody; + + const deferred = Promise.defer(); + const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite"); + txn.onerror = deferred.reject; + + // first see if we already have an entry for this request. + this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { + if (existing) { + // this entry matches the request - return it. + logger.log( + `already have key request outstanding for ` + + `${requestBody.room_id} / ${requestBody.session_id}: ` + + `not sending another`, + ); + deferred.resolve(existing); + return; + } + + // we got to the end of the list without finding a match + // - add the new request. + logger.log( + `enqueueing key request for ${requestBody.room_id} / ` + + requestBody.session_id, + ); + txn.oncomplete = () => { deferred.resolve(request); }; + const store = txn.objectStore("outgoingRoomKeyRequests"); + store.add(request); + }); + + return deferred.promise; + } + + /** + * Look for an existing room key request + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * existing request to look for + * + * @return {Promise} resolves to the matching + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * not found + */ + getOutgoingRoomKeyRequest(requestBody) { + const deferred = Promise.defer(); + + const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly"); + txn.onerror = deferred.reject; + + this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { + deferred.resolve(existing); + }); + return deferred.promise; + } + + /** + * look for an existing room key request in the db + * + * @private + * @param {IDBTransaction} txn database transaction + * @param {module:crypto~RoomKeyRequestBody} requestBody + * existing request to look for + * @param {Function} callback function to call with the results of the + * search. Either passed a matching + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * not found. + */ + _getOutgoingRoomKeyRequest(txn, requestBody, callback) { + const store = txn.objectStore("outgoingRoomKeyRequests"); + + const idx = store.index("session"); + const cursorReq = idx.openCursor([ + requestBody.room_id, + requestBody.session_id, + ]); + + cursorReq.onsuccess = (ev) => { + const cursor = ev.target.result; + if(!cursor) { + // no match found + callback(null); + return; + } + + const existing = cursor.value; + + if (utils.deepCompare(existing.requestBody, requestBody)) { + // got a match + callback(existing); + return; + } + + // look at the next entry in the index + cursor.continue(); + }; + } + + /** + * Look for room key requests by state + * + * @param {Array} wantedStates list of acceptable states + * + * @return {Promise} resolves to the a + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states. If there are multiple + * requests in those states, an arbitrary one is chosen. + */ + getOutgoingRoomKeyRequestByState(wantedStates) { + if (wantedStates.length === 0) { + return Promise.resolve(null); + } + + // this is a bit tortuous because we need to make sure we do the lookup + // in a single transaction, to avoid having a race with the insertion + // code. + + // index into the wantedStates array + let stateIndex = 0; + let result; + + function onsuccess(ev) { + const cursor = ev.target.result; + if (cursor) { + // got a match + result = cursor.value; + return; + } + + // try the next state in the list + stateIndex++; + if (stateIndex >= wantedStates.length) { + // no matches + return; + } + + const wantedState = wantedStates[stateIndex]; + const cursorReq = ev.target.source.openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + } + + const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly"); + const store = txn.objectStore("outgoingRoomKeyRequests"); + + const wantedState = wantedStates[stateIndex]; + const cursorReq = store.index("state").openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + + return promiseifyTxn(txn).then(() => result); + } + + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + let stateIndex = 0; + const results = []; + + function onsuccess(ev) { + const cursor = ev.target.result; + if (cursor) { + const keyReq = cursor.value; + if (keyReq.recipients.includes({userId, deviceId})) { + results.push(keyReq); + } + cursor.continue(); + } else { + // try the next state in the list + stateIndex++; + if (stateIndex >= wantedStates.length) { + // no matches + return; + } + + const wantedState = wantedStates[stateIndex]; + const cursorReq = ev.target.source.openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + } + } + + const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly"); + const store = txn.objectStore("outgoingRoomKeyRequests"); + + const wantedState = wantedStates[stateIndex]; + const cursorReq = store.index("state").openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + + return promiseifyTxn(txn).then(() => results); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * @param {Object} updates name/value map of updates to apply + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + updateOutgoingRoomKeyRequest(requestId, expectedState, updates) { + let result = null; + + function onsuccess(ev) { + const cursor = ev.target.result; + if (!cursor) { + return; + } + const data = cursor.value; + if (data.state != expectedState) { + logger.warn( + `Cannot update room key request from ${expectedState} ` + + `as it was already updated to ${data.state}`, + ); + return; + } + Object.assign(data, updates); + cursor.update(data); + result = data; + } + + const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite"); + const cursorReq = txn.objectStore("outgoingRoomKeyRequests") + .openCursor(requestId); + cursorReq.onsuccess = onsuccess; + return promiseifyTxn(txn).then(() => result); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * + * @returns {Promise} resolves once the operation is completed + */ + deleteOutgoingRoomKeyRequest(requestId, expectedState) { + const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite"); + const cursorReq = txn.objectStore("outgoingRoomKeyRequests") + .openCursor(requestId); + cursorReq.onsuccess = (ev) => { + const cursor = ev.target.result; + if (!cursor) { + return; + } + const data = cursor.value; + if (data.state != expectedState) { + logger.warn( + `Cannot delete room key request in state ${data.state} ` + + `(expected ${expectedState})`, + ); + return; + } + cursor.delete(); + }; + return promiseifyTxn(txn); + } + + // Olm Account + + getAccount(txn, func) { + const objectStore = txn.objectStore("account"); + const getReq = objectStore.get("-"); + getReq.onsuccess = function() { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, e); + } + }; + } + + storeAccount(txn, newData) { + const objectStore = txn.objectStore("account"); + objectStore.put(newData, "-"); + } + + // Olm Sessions + + countEndToEndSessions(txn, func) { + const objectStore = txn.objectStore("sessions"); + const countReq = objectStore.count(); + countReq.onsuccess = function() { + func(countReq.result); + }; + } + + getEndToEndSessions(deviceKey, txn, func) { + const objectStore = txn.objectStore("sessions"); + const idx = objectStore.index("deviceKey"); + const getReq = idx.openCursor(deviceKey); + const results = {}; + getReq.onsuccess = function() { + const cursor = getReq.result; + if (cursor) { + results[cursor.value.sessionId] = { + session: cursor.value.session, + lastReceivedMessageTs: cursor.value.lastReceivedMessageTs, + }; + cursor.continue(); + } else { + try { + func(results); + } catch (e) { + abortWithException(txn, e); + } + } + }; + } + + getEndToEndSession(deviceKey, sessionId, txn, func) { + const objectStore = txn.objectStore("sessions"); + const getReq = objectStore.get([deviceKey, sessionId]); + getReq.onsuccess = function() { + try { + if (getReq.result) { + func({ + session: getReq.result.session, + lastReceivedMessageTs: getReq.result.lastReceivedMessageTs, + }); + } else { + func(null); + } + } catch (e) { + abortWithException(txn, e); + } + }; + } + + getAllEndToEndSessions(txn, func) { + const objectStore = txn.objectStore("sessions"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function() { + const cursor = getReq.result; + if (cursor) { + func(cursor.value); + cursor.continue(); + } else { + try { + func(null); + } catch (e) { + abortWithException(txn, e); + } + } + }; + } + + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { + const objectStore = txn.objectStore("sessions"); + objectStore.put({ + deviceKey, + sessionId, + session: sessionInfo.session, + lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs, + }); + } + + // Inbound group sessions + + getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { + const objectStore = txn.objectStore("inbound_group_sessions"); + const getReq = objectStore.get([senderCurve25519Key, sessionId]); + getReq.onsuccess = function() { + try { + if (getReq.result) { + func(getReq.result.session); + } else { + func(null); + } + } catch (e) { + abortWithException(txn, e); + } + }; + } + + getAllEndToEndInboundGroupSessions(txn, func) { + const objectStore = txn.objectStore("inbound_group_sessions"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function() { + const cursor = getReq.result; + if (cursor) { + try { + func({ + senderKey: cursor.value.senderCurve25519Key, + sessionId: cursor.value.sessionId, + sessionData: cursor.value.session, + }); + } catch (e) { + abortWithException(txn, e); + } + cursor.continue(); + } else { + try { + func(null); + } catch (e) { + abortWithException(txn, e); + } + } + }; + } + + addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + const objectStore = txn.objectStore("inbound_group_sessions"); + const addReq = objectStore.add({ + senderCurve25519Key, sessionId, session: sessionData, + }); + addReq.onerror = (ev) => { + if (addReq.error.name === 'ConstraintError') { + // This stops the error from triggering the txn's onerror + ev.stopPropagation(); + // ...and this stops it from aborting the transaction + ev.preventDefault(); + logger.log( + "Ignoring duplicate inbound group session: " + + senderCurve25519Key + " / " + sessionId, + ); + } else { + abortWithException(txn, new Error( + "Failed to add inbound group session: " + addReq.error, + )); + } + }; + } + + storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + const objectStore = txn.objectStore("inbound_group_sessions"); + objectStore.put({ + senderCurve25519Key, sessionId, session: sessionData, + }); + } + + getEndToEndDeviceData(txn, func) { + const objectStore = txn.objectStore("device_data"); + const getReq = objectStore.get("-"); + getReq.onsuccess = function() { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, e); + } + }; + } + + storeEndToEndDeviceData(deviceData, txn) { + const objectStore = txn.objectStore("device_data"); + objectStore.put(deviceData, "-"); + } + + storeEndToEndRoom(roomId, roomInfo, txn) { + const objectStore = txn.objectStore("rooms"); + objectStore.put(roomInfo, roomId); + } + + getEndToEndRooms(txn, func) { + const rooms = {}; + const objectStore = txn.objectStore("rooms"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function() { + const cursor = getReq.result; + if (cursor) { + rooms[cursor.key] = cursor.value; + cursor.continue(); + } else { + try { + func(rooms); + } catch (e) { + abortWithException(txn, e); + } + } + }; + } + + // session backups + + getSessionsNeedingBackup(limit) { + return new Promise((resolve, reject) => { + const sessions = []; + + const txn = this._db.transaction( + ["sessions_needing_backup", "inbound_group_sessions"], + "readonly", + ); + txn.onerror = reject; + txn.oncomplete = function() { + resolve(sessions); + }; + const objectStore = txn.objectStore("sessions_needing_backup"); + const sessionStore = txn.objectStore("inbound_group_sessions"); + const getReq = objectStore.openCursor(); + getReq.onsuccess = function() { + const cursor = getReq.result; + if (cursor) { + const sessionGetReq = sessionStore.get(cursor.key); + sessionGetReq.onsuccess = function() { + sessions.push({ + senderKey: sessionGetReq.result.senderCurve25519Key, + sessionId: sessionGetReq.result.sessionId, + sessionData: sessionGetReq.result.session, + }); + }; + if (!limit || sessions.length < limit) { + cursor.continue(); + } + } + }; + }); + } + + countSessionsNeedingBackup(txn) { + if (!txn) { + txn = this._db.transaction("sessions_needing_backup", "readonly"); + } + const objectStore = txn.objectStore("sessions_needing_backup"); + return new Promise((resolve, reject) => { + const req = objectStore.count(); + req.onerror = reject; + req.onsuccess = () => resolve(req.result); + }); + } + + unmarkSessionsNeedingBackup(sessions, txn) { + if (!txn) { + txn = this._db.transaction("sessions_needing_backup", "readwrite"); + } + const objectStore = txn.objectStore("sessions_needing_backup"); + return Promise.all(sessions.map((session) => { + return new Promise((resolve, reject) => { + const req = objectStore.delete([session.senderKey, session.sessionId]); + req.onsuccess = resolve; + req.onerror = reject; + }); + })); + } + + markSessionsNeedingBackup(sessions, txn) { + if (!txn) { + txn = this._db.transaction("sessions_needing_backup", "readwrite"); + } + const objectStore = txn.objectStore("sessions_needing_backup"); + return Promise.all(sessions.map((session) => { + return new Promise((resolve, reject) => { + const req = objectStore.put({ + senderCurve25519Key: session.senderKey, + sessionId: session.sessionId, + }); + req.onsuccess = resolve; + req.onerror = reject; + }); + })); + } + + doTxn(mode, stores, func) { + const txn = this._db.transaction(stores, mode); + const promise = promiseifyTxn(txn); + const result = func(txn); + return promise.then(() => { + return result; + }); + } +} + +export function upgradeDatabase(db, oldVersion) { + logger.log( + `Upgrading IndexedDBCryptoStore from version ${oldVersion}` + + ` to ${VERSION}`, + ); + if (oldVersion < 1) { // The database did not previously exist. + createDatabase(db); + } + if (oldVersion < 2) { + db.createObjectStore("account"); + } + if (oldVersion < 3) { + const sessionsStore = db.createObjectStore("sessions", { + keyPath: ["deviceKey", "sessionId"], + }); + sessionsStore.createIndex("deviceKey", "deviceKey"); + } + if (oldVersion < 4) { + db.createObjectStore("inbound_group_sessions", { + keyPath: ["senderCurve25519Key", "sessionId"], + }); + } + if (oldVersion < 5) { + db.createObjectStore("device_data"); + } + if (oldVersion < 6) { + db.createObjectStore("rooms"); + } + if (oldVersion < 7) { + db.createObjectStore("sessions_needing_backup", { + keyPath: ["senderCurve25519Key", "sessionId"], + }); + } + // Expand as needed. +} + +function createDatabase(db) { + const outgoingRoomKeyRequestsStore = + db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" }); + + // we assume that the RoomKeyRequestBody will have room_id and session_id + // properties, to make the index efficient. + outgoingRoomKeyRequestsStore.createIndex("session", + ["requestBody.room_id", "requestBody.session_id"], + ); + + outgoingRoomKeyRequestsStore.createIndex("state", "state"); +} + +/* + * Aborts a transaction with a given exception + * The transaction promise will be rejected with this exception. + */ +function abortWithException(txn, e) { + // We cheekily stick our exception onto the transaction object here + // We could alternatively make the thing we pass back to the app + // an object containing the transaction and exception. + txn._mx_abortexception = e; + try { + txn.abort(); + } catch (e) { + // sometimes we won't be able to abort the transaction + // (ie. if it's aborted or completed) + } +} + +function promiseifyTxn(txn) { + return new Promise((resolve, reject) => { + txn.oncomplete = () => { + if (txn._mx_abortexception !== undefined) { + reject(txn._mx_abortexception); + } + resolve(); + }; + txn.onerror = (event) => { + if (txn._mx_abortexception !== undefined) { + reject(txn._mx_abortexception); + } else { + logger.log("Error performing indexeddb txn", event); + reject(event.target.error); + } + }; + txn.onabort = (event) => { + if (txn._mx_abortexception !== undefined) { + reject(txn._mx_abortexception); + } else { + logger.log("Error performing indexeddb txn", event); + reject(event.target.error); + } + }; + }); +} diff --git a/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.js b/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.js new file mode 100644 index 000000000..480ff9f1a --- /dev/null +++ b/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.js @@ -0,0 +1,558 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ + +import Promise from 'bluebird'; + +import logger from '../../logger'; +import LocalStorageCryptoStore from './localStorage-crypto-store'; +import MemoryCryptoStore from './memory-crypto-store'; +import * as IndexedDBCryptoStoreBackend from './indexeddb-crypto-store-backend'; +import {InvalidCryptoStoreError} from '../../errors'; +import * as IndexedDBHelpers from "../../indexeddb-helpers"; + +/** + * Internal module. indexeddb storage for e2e. + * + * @module + */ + +/** + * An implementation of CryptoStore, which is normally backed by an indexeddb, + * but with fallback to MemoryCryptoStore. + * + * @implements {module:crypto/store/base~CryptoStore} + */ +export default class IndexedDBCryptoStore { + /** + * Create a new IndexedDBCryptoStore + * + * @param {IDBFactory} indexedDB global indexedDB instance + * @param {string} dbName name of db to connect to + */ + constructor(indexedDB, dbName) { + this._indexedDB = indexedDB; + this._dbName = dbName; + this._backendPromise = null; + } + + static exists(indexedDB, dbName) { + return IndexedDBHelpers.exists(indexedDB, dbName); + } + + /** + * Ensure the database exists and is up-to-date, or fall back to + * a local storage or in-memory store. + * + * @return {Promise} resolves to either an IndexedDBCryptoStoreBackend.Backend, + * or a MemoryCryptoStore + */ + _connect() { + if (this._backendPromise) { + return this._backendPromise; + } + + this._backendPromise = new Promise((resolve, reject) => { + if (!this._indexedDB) { + reject(new Error('no indexeddb support available')); + return; + } + + logger.log(`connecting to indexeddb ${this._dbName}`); + + const req = this._indexedDB.open( + this._dbName, IndexedDBCryptoStoreBackend.VERSION, + ); + + req.onupgradeneeded = (ev) => { + const db = ev.target.result; + const oldVersion = ev.oldVersion; + IndexedDBCryptoStoreBackend.upgradeDatabase(db, oldVersion); + }; + + req.onblocked = () => { + logger.log( + `can't yet open IndexedDBCryptoStore because it is open elsewhere`, + ); + }; + + req.onerror = (ev) => { + logger.log("Error connecting to indexeddb", ev); + reject(ev.target.error); + }; + + req.onsuccess = (r) => { + const db = r.target.result; + + logger.log(`connected to indexeddb ${this._dbName}`); + resolve(new IndexedDBCryptoStoreBackend.Backend(db)); + }; + }).then((backend) => { + // Edge has IndexedDB but doesn't support compund keys which we use fairly extensively. + // Try a dummy query which will fail if the browser doesn't support compund keys, so + // we can fall back to a different backend. + return backend.doTxn( + 'readonly', + [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], + (txn) => { + backend.getEndToEndInboundGroupSession('', '', txn, () => {}); + }).then(() => { + return backend; + }, + ); + }).catch((e) => { + if (e.name === 'VersionError') { + logger.warn("Crypto DB is too new for us to use!", e); + // don't fall back to a different store: the user has crypto data + // in this db so we should use it or nothing at all. + throw new InvalidCryptoStoreError(InvalidCryptoStoreError.TOO_NEW); + } + logger.warn( + `unable to connect to indexeddb ${this._dbName}` + + `: falling back to localStorage store: ${e}`, + ); + + try { + return new LocalStorageCryptoStore(global.localStorage); + } catch (e) { + logger.warn( + `unable to open localStorage: falling back to in-memory store: ${e}`, + ); + return new MemoryCryptoStore(); + } + }); + + return this._backendPromise; + } + + /** + * Delete all data from this store. + * + * @returns {Promise} resolves when the store has been cleared. + */ + deleteAllData() { + return new Promise((resolve, reject) => { + if (!this._indexedDB) { + reject(new Error('no indexeddb support available')); + return; + } + + logger.log(`Removing indexeddb instance: ${this._dbName}`); + const req = this._indexedDB.deleteDatabase(this._dbName); + + req.onblocked = () => { + logger.log( + `can't yet delete IndexedDBCryptoStore because it is open elsewhere`, + ); + }; + + req.onerror = (ev) => { + logger.log("Error deleting data from indexeddb", ev); + reject(ev.target.error); + }; + + req.onsuccess = () => { + logger.log(`Removed indexeddb instance: ${this._dbName}`); + resolve(); + }; + }).catch((e) => { + // in firefox, with indexedDB disabled, this fails with a + // DOMError. We treat this as non-fatal, so that people can + // still use the app. + logger.warn(`unable to delete IndexedDBCryptoStore: ${e}`); + }); + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + getOrAddOutgoingRoomKeyRequest(request) { + return this._connect().then((backend) => { + return backend.getOrAddOutgoingRoomKeyRequest(request); + }); + } + + /** + * Look for an existing room key request + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * existing request to look for + * + * @return {Promise} resolves to the matching + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * not found + */ + getOutgoingRoomKeyRequest(requestBody) { + return this._connect().then((backend) => { + return backend.getOutgoingRoomKeyRequest(requestBody); + }); + } + + /** + * Look for room key requests by state + * + * @param {Array} wantedStates list of acceptable states + * + * @return {Promise} resolves to the a + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states. If there are multiple + * requests in those states, an arbitrary one is chosen. + */ + getOutgoingRoomKeyRequestByState(wantedStates) { + return this._connect().then((backend) => { + return backend.getOutgoingRoomKeyRequestByState(wantedStates); + }); + } + + /** + * Look for room key requests by target device and state + * + * @param {string} userId Target user ID + * @param {string} deviceId Target device ID + * @param {Array} wantedStates list of acceptable states + * + * @return {Promise} resolves to a list of all the + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + */ + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + return this._connect().then((backend) => { + return backend.getOutgoingRoomKeyRequestsByTarget( + userId, deviceId, wantedStates, + ); + }); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * @param {Object} updates name/value map of updates to apply + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + updateOutgoingRoomKeyRequest(requestId, expectedState, updates) { + return this._connect().then((backend) => { + return backend.updateOutgoingRoomKeyRequest( + requestId, expectedState, updates, + ); + }); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * + * @returns {Promise} resolves once the operation is completed + */ + deleteOutgoingRoomKeyRequest(requestId, expectedState) { + return this._connect().then((backend) => { + return backend.deleteOutgoingRoomKeyRequest(requestId, expectedState); + }); + } + + // Olm Account + + /* + * Get the account pickle from the store. + * This requires an active transaction. See doTxn(). + * + * @param {*} txn An active transaction. See doTxn(). + * @param {function(string)} func Called with the account pickle + */ + getAccount(txn, func) { + this._backendPromise.value().getAccount(txn, func); + } + + /* + * Write the account pickle to the store. + * This requires an active transaction. See doTxn(). + * + * @param {*} txn An active transaction. See doTxn(). + * @param {string} newData The new account pickle to store. + */ + storeAccount(txn, newData) { + this._backendPromise.value().storeAccount(txn, newData); + } + + // Olm sessions + + /** + * Returns the number of end-to-end sessions in the store + * @param {*} txn An active transaction. See doTxn(). + * @param {function(int)} func Called with the count of sessions + */ + countEndToEndSessions(txn, func) { + this._backendPromise.value().countEndToEndSessions(txn, func); + } + + /** + * Retrieve a specific end-to-end session between the logged-in user + * and another device. + * @param {string} deviceKey The public key of the other device. + * @param {string} sessionId The ID of the session to retrieve + * @param {*} txn An active transaction. See doTxn(). + * @param {function(object)} func Called with A map from sessionId + * to session information object with 'session' key being the + * Base64 end-to-end session and lastReceivedMessageTs being the + * timestamp in milliseconds at which the session last received + * a message. + */ + getEndToEndSession(deviceKey, sessionId, txn, func) { + this._backendPromise.value().getEndToEndSession(deviceKey, sessionId, txn, func); + } + + /** + * Retrieve the end-to-end sessions between the logged-in user and another + * device. + * @param {string} deviceKey The public key of the other device. + * @param {*} txn An active transaction. See doTxn(). + * @param {function(object)} func Called with A map from sessionId + * to session information object with 'session' key being the + * Base64 end-to-end session and lastReceivedMessageTs being the + * timestamp in milliseconds at which the session last received + * a message. + */ + getEndToEndSessions(deviceKey, txn, func) { + this._backendPromise.value().getEndToEndSessions(deviceKey, txn, func); + } + + /** + * Retrieve all end-to-end sessions + * @param {*} txn An active transaction. See doTxn(). + * @param {function(object)} func Called one for each session with + * an object with, deviceKey, lastReceivedMessageTs, sessionId + * and session keys. + */ + getAllEndToEndSessions(txn, func) { + this._backendPromise.value().getAllEndToEndSessions(txn, func); + } + + /** + * Store a session between the logged-in user and another device + * @param {string} deviceKey The public key of the other device. + * @param {string} sessionId The ID for this end-to-end session. + * @param {string} sessionInfo Session information object + * @param {*} txn An active transaction. See doTxn(). + */ + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { + this._backendPromise.value().storeEndToEndSession( + deviceKey, sessionId, sessionInfo, txn, + ); + } + + // Inbound group saessions + + /** + * Retrieve the end-to-end inbound group session for a given + * server key and session ID + * @param {string} senderCurve25519Key The sender's curve 25519 key + * @param {string} sessionId The ID of the session + * @param {*} txn An active transaction. See doTxn(). + * @param {function(object)} func Called with A map from sessionId + * to Base64 end-to-end session. + */ + getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { + this._backendPromise.value().getEndToEndInboundGroupSession( + senderCurve25519Key, sessionId, txn, func, + ); + } + + /** + * Fetches all inbound group sessions in the store + * @param {*} txn An active transaction. See doTxn(). + * @param {function(object)} func Called once for each group session + * in the store with an object having keys {senderKey, sessionId, + * sessionData}, then once with null to indicate the end of the list. + */ + getAllEndToEndInboundGroupSessions(txn, func) { + this._backendPromise.value().getAllEndToEndInboundGroupSessions(txn, func); + } + + /** + * Adds an end-to-end inbound group session to the store. + * If there already exists an inbound group session with the same + * senderCurve25519Key and sessionID, the session will not be added. + * @param {string} senderCurve25519Key The sender's curve 25519 key + * @param {string} sessionId The ID of the session + * @param {object} sessionData The session data structure + * @param {*} txn An active transaction. See doTxn(). + */ + addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + this._backendPromise.value().addEndToEndInboundGroupSession( + senderCurve25519Key, sessionId, sessionData, txn, + ); + } + + /** + * Writes an end-to-end inbound group session to the store. + * If there already exists an inbound group session with the same + * senderCurve25519Key and sessionID, it will be overwritten. + * @param {string} senderCurve25519Key The sender's curve 25519 key + * @param {string} sessionId The ID of the session + * @param {object} sessionData The session data structure + * @param {*} txn An active transaction. See doTxn(). + */ + storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + this._backendPromise.value().storeEndToEndInboundGroupSession( + senderCurve25519Key, sessionId, sessionData, txn, + ); + } + + // End-to-end device tracking + + /** + * Store the state of all tracked devices + * This contains devices for each user, a tracking state for each user + * and a sync token matching the point in time the snapshot represents. + * These all need to be written out in full each time such that the snapshot + * is always consistent, so they are stored in one object. + * + * @param {Object} deviceData + * @param {*} txn An active transaction. See doTxn(). + */ + storeEndToEndDeviceData(deviceData, txn) { + this._backendPromise.value().storeEndToEndDeviceData(deviceData, txn); + } + + /** + * Get the state of all tracked devices + * + * @param {*} txn An active transaction. See doTxn(). + * @param {function(Object)} func Function called with the + * device data + */ + getEndToEndDeviceData(txn, func) { + this._backendPromise.value().getEndToEndDeviceData(txn, func); + } + + // End to End Rooms + + /** + * Store the end-to-end state for a room. + * @param {string} roomId The room's ID. + * @param {object} roomInfo The end-to-end info for the room. + * @param {*} txn An active transaction. See doTxn(). + */ + storeEndToEndRoom(roomId, roomInfo, txn) { + this._backendPromise.value().storeEndToEndRoom(roomId, roomInfo, txn); + } + + /** + * Get an object of roomId->roomInfo for all e2e rooms in the store + * @param {*} txn An active transaction. See doTxn(). + * @param {function(Object)} func Function called with the end to end encrypted rooms + */ + getEndToEndRooms(txn, func) { + this._backendPromise.value().getEndToEndRooms(txn, func); + } + + // session backups + + /** + * Get the inbound group sessions that need to be backed up. + * @param {integer} limit The maximum number of sessions to retrieve. 0 + * for no limit. + * @returns {Promise} resolves to an array of inbound group sessions + */ + getSessionsNeedingBackup(limit) { + return this._connect().then((backend) => { + return backend.getSessionsNeedingBackup(limit); + }); + } + + /** + * Count the inbound group sessions that need to be backed up. + * @param {*} txn An active transaction. See doTxn(). (optional) + * @returns {Promise} resolves to the number of sessions + */ + countSessionsNeedingBackup(txn) { + return this._connect().then((backend) => { + return backend.countSessionsNeedingBackup(txn); + }); + } + + /** + * Unmark sessions as needing to be backed up. + * @param {Array} sessions The sessions that need to be backed up. + * @param {*} txn An active transaction. See doTxn(). (optional) + * @returns {Promise} resolves when the sessions are unmarked + */ + unmarkSessionsNeedingBackup(sessions, txn) { + return this._connect().then((backend) => { + return backend.unmarkSessionsNeedingBackup(sessions, txn); + }); + } + + /** + * Mark sessions as needing to be backed up. + * @param {Array} sessions The sessions that need to be backed up. + * @param {*} txn An active transaction. See doTxn(). (optional) + * @returns {Promise} resolves when the sessions are marked + */ + markSessionsNeedingBackup(sessions, txn) { + return this._connect().then((backend) => { + return backend.markSessionsNeedingBackup(sessions, txn); + }); + } + + /** + * Perform a transaction on the crypto store. Any store methods + * that require a transaction (txn) object to be passed in may + * only be called within a callback of either this function or + * one of the store functions operating on the same transaction. + * + * @param {string} mode 'readwrite' if you need to call setter + * functions with this transaction. Otherwise, 'readonly'. + * @param {string[]} stores List IndexedDBCryptoStore.STORE_* + * options representing all types of object that will be + * accessed or written to with this transaction. + * @param {function(*)} func Function called with the + * transaction object: an opaque object that should be passed + * to store functions. + * @return {Promise} Promise that resolves with the result of the `func` + * when the transaction is complete. If the backend is + * async (ie. the indexeddb backend) any of the callback + * functions throwing an exception will cause this promise to + * reject with that exception. On synchronous backends, the + * exception will propagate to the caller of the getFoo method. + */ + doTxn(mode, stores, func) { + return this._connect().then((backend) => { + return backend.doTxn(mode, stores, func); + }); + } +} + +IndexedDBCryptoStore.STORE_ACCOUNT = 'account'; +IndexedDBCryptoStore.STORE_SESSIONS = 'sessions'; +IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; +IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data'; +IndexedDBCryptoStore.STORE_ROOMS = 'rooms'; +IndexedDBCryptoStore.STORE_BACKUP = 'sessions_needing_backup'; diff --git a/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.js b/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.js new file mode 100644 index 000000000..af6043af1 --- /dev/null +++ b/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.js @@ -0,0 +1,306 @@ +/* +Copyright 2017, 2018 New Vector 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. +*/ + +import Promise from 'bluebird'; + +import logger from '../../logger'; +import MemoryCryptoStore from './memory-crypto-store.js'; + +/** + * Internal module. Partial localStorage backed storage for e2e. + * This is not a full crypto store, just the in-memory store with + * some things backed by localStorage. It exists because indexedDB + * is broken in Firefox private mode or set to, "will not remember + * history". + * + * @module + */ + +const E2E_PREFIX = "crypto."; +const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; +const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; +const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; +const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/"; +const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup"; + +function keyEndToEndSessions(deviceKey) { + return E2E_PREFIX + "sessions/" + deviceKey; +} + +function keyEndToEndInboundGroupSession(senderKey, sessionId) { + return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId; +} + +function keyEndToEndRoomsPrefix(roomId) { + return KEY_ROOMS_PREFIX + roomId; +} + +/** + * @implements {module:crypto/store/base~CryptoStore} + */ +export default class LocalStorageCryptoStore extends MemoryCryptoStore { + constructor(webStore) { + super(); + this.store = webStore; + } + + static exists(webStore) { + const length = webStore.length; + for (let i = 0; i < length; i++) { + if (webStore.key(i).startsWith(E2E_PREFIX)) { + return true; + } + } + return false; + } + + // Olm Sessions + + countEndToEndSessions(txn, func) { + let count = 0; + for (let i = 0; i < this.store.length; ++i) { + if (this.store.key(i).startsWith(keyEndToEndSessions(''))) ++count; + } + func(count); + } + + _getEndToEndSessions(deviceKey, txn, func) { + const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey)); + const fixedSessions = {}; + + // fix up any old sessions to be objects rather than just the base64 pickle + for (const [sid, val] of Object.entries(sessions || {})) { + if (typeof val === 'string') { + fixedSessions[sid] = { + session: val, + }; + } else { + fixedSessions[sid] = val; + } + } + + return fixedSessions; + } + + getEndToEndSession(deviceKey, sessionId, txn, func) { + const sessions = this._getEndToEndSessions(deviceKey); + func(sessions[sessionId] || {}); + } + + getEndToEndSessions(deviceKey, txn, func) { + func(this._getEndToEndSessions(deviceKey) || {}); + } + + getAllEndToEndSessions(txn, func) { + for (let i = 0; i < this.store.length; ++i) { + if (this.store.key(i).startsWith(keyEndToEndSessions(''))) { + const deviceKey = this.store.key(i).split('/')[1]; + for (const sess of Object.values(this._getEndToEndSessions(deviceKey))) { + func(sess); + } + } + } + } + + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { + const sessions = this._getEndToEndSessions(deviceKey) || {}; + sessions[sessionId] = sessionInfo; + setJsonItem( + this.store, keyEndToEndSessions(deviceKey), sessions, + ); + } + + // Inbound Group Sessions + + getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { + func(getJsonItem( + this.store, + keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), + )); + } + + getAllEndToEndInboundGroupSessions(txn, func) { + for (let i = 0; i < this.store.length; ++i) { + const key = this.store.key(i); + if (key.startsWith(KEY_INBOUND_SESSION_PREFIX)) { + // we can't use split, as the components we are trying to split out + // might themselves contain '/' characters. We rely on the + // senderKey being a (32-byte) curve25519 key, base64-encoded + // (hence 43 characters long). + + func({ + senderKey: key.substr(KEY_INBOUND_SESSION_PREFIX.length, 43), + sessionId: key.substr(KEY_INBOUND_SESSION_PREFIX.length + 44), + sessionData: getJsonItem(this.store, key), + }); + } + } + func(null); + } + + addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + const existing = getJsonItem( + this.store, + keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), + ); + if (!existing) { + this.storeEndToEndInboundGroupSession( + senderCurve25519Key, sessionId, sessionData, txn, + ); + } + } + + storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + setJsonItem( + this.store, + keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), + sessionData, + ); + } + + getEndToEndDeviceData(txn, func) { + func(getJsonItem( + this.store, KEY_DEVICE_DATA, + )); + } + + storeEndToEndDeviceData(deviceData, txn) { + setJsonItem( + this.store, KEY_DEVICE_DATA, deviceData, + ); + } + + storeEndToEndRoom(roomId, roomInfo, txn) { + setJsonItem( + this.store, keyEndToEndRoomsPrefix(roomId), roomInfo, + ); + } + + getEndToEndRooms(txn, func) { + const result = {}; + const prefix = keyEndToEndRoomsPrefix(''); + + for (let i = 0; i < this.store.length; ++i) { + const key = this.store.key(i); + if (key.startsWith(prefix)) { + const roomId = key.substr(prefix.length); + result[roomId] = getJsonItem(this.store, key); + } + } + func(result); + } + + getSessionsNeedingBackup(limit) { + const sessionsNeedingBackup + = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + const sessions = []; + + for (const session in sessionsNeedingBackup) { + if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) { + // see getAllEndToEndInboundGroupSessions for the magic number explanations + const senderKey = session.substr(0, 43); + const sessionId = session.substr(44); + this.getEndToEndInboundGroupSession( + senderKey, sessionId, null, + (sessionData) => { + sessions.push({ + senderKey: senderKey, + sessionId: sessionId, + sessionData: sessionData, + }); + }, + ); + if (limit && session.length >= limit) { + break; + } + } + } + return Promise.resolve(sessions); + } + + countSessionsNeedingBackup() { + const sessionsNeedingBackup + = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + return Promise.resolve(Object.keys(sessionsNeedingBackup).length); + } + + unmarkSessionsNeedingBackup(sessions) { + const sessionsNeedingBackup + = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + for (const session of sessions) { + delete sessionsNeedingBackup[session.senderKey + '/' + session.sessionId]; + } + setJsonItem( + this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup, + ); + return Promise.resolve(); + } + + markSessionsNeedingBackup(sessions) { + const sessionsNeedingBackup + = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {}; + for (const session of sessions) { + sessionsNeedingBackup[session.senderKey + '/' + session.sessionId] = true; + } + setJsonItem( + this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup, + ); + return Promise.resolve(); + } + + /** + * Delete all data from this store. + * + * @returns {Promise} Promise which resolves when the store has been cleared. + */ + deleteAllData() { + this.store.removeItem(KEY_END_TO_END_ACCOUNT); + return Promise.resolve(); + } + + // Olm account + + getAccount(txn, func) { + const account = getJsonItem(this.store, KEY_END_TO_END_ACCOUNT); + func(account); + } + + storeAccount(txn, newData) { + setJsonItem( + this.store, KEY_END_TO_END_ACCOUNT, newData, + ); + } + + doTxn(mode, stores, func) { + return Promise.resolve(func(null)); + } +} + +function getJsonItem(store, key) { + try { + // if the key is absent, store.getItem() returns null, and + // JSON.parse(null) === null, so this returns null. + return JSON.parse(store.getItem(key)); + } catch (e) { + logger.log("Error: Failed to get key %s: %s", key, e.stack || e); + logger.log(e.stack); + } + return null; +} + +function setJsonItem(store, key, val) { + store.setItem(key, JSON.stringify(val)); +} diff --git a/matrix-js-sdk/src/crypto/store/memory-crypto-store.js b/matrix-js-sdk/src/crypto/store/memory-crypto-store.js new file mode 100644 index 000000000..49df5f238 --- /dev/null +++ b/matrix-js-sdk/src/crypto/store/memory-crypto-store.js @@ -0,0 +1,364 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ + +import Promise from 'bluebird'; + +import logger from '../../logger'; +import utils from '../../utils'; + +/** + * Internal module. in-memory storage for e2e. + * + * @module + */ + +/** + * @implements {module:crypto/store/base~CryptoStore} + */ +export default class MemoryCryptoStore { + constructor() { + this._outgoingRoomKeyRequests = []; + this._account = null; + + // Map of {devicekey -> {sessionId -> session pickle}} + this._sessions = {}; + // Map of {senderCurve25519Key+'/'+sessionId -> session data object} + this._inboundGroupSessions = {}; + // Opaque device data object + this._deviceData = null; + // roomId -> Opaque roomInfo object + this._rooms = {}; + // Set of {senderCurve25519Key+'/'+sessionId} + this._sessionsNeedingBackup = {}; + } + + /** + * Delete all data from this store. + * + * @returns {Promise} Promise which resolves when the store has been cleared. + */ + deleteAllData() { + return Promise.resolve(); + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + getOrAddOutgoingRoomKeyRequest(request) { + const requestBody = request.requestBody; + + return Promise.try(() => { + // first see if we already have an entry for this request. + const existing = this._getOutgoingRoomKeyRequest(requestBody); + + if (existing) { + // this entry matches the request - return it. + logger.log( + `already have key request outstanding for ` + + `${requestBody.room_id} / ${requestBody.session_id}: ` + + `not sending another`, + ); + return existing; + } + + // we got to the end of the list without finding a match + // - add the new request. + logger.log( + `enqueueing key request for ${requestBody.room_id} / ` + + requestBody.session_id, + ); + this._outgoingRoomKeyRequests.push(request); + return request; + }); + } + + /** + * Look for an existing room key request + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * existing request to look for + * + * @return {Promise} resolves to the matching + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * not found + */ + getOutgoingRoomKeyRequest(requestBody) { + return Promise.resolve(this._getOutgoingRoomKeyRequest(requestBody)); + } + + /** + * Looks for existing room key request, and returns the result synchronously. + * + * @internal + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * existing request to look for + * + * @return {module:crypto/store/base~OutgoingRoomKeyRequest?} + * the matching request, or null if not found + */ + _getOutgoingRoomKeyRequest(requestBody) { + for (const existing of this._outgoingRoomKeyRequests) { + if (utils.deepCompare(existing.requestBody, requestBody)) { + return existing; + } + } + return null; + } + + /** + * Look for room key requests by state + * + * @param {Array} wantedStates list of acceptable states + * + * @return {Promise} resolves to the a + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states + */ + getOutgoingRoomKeyRequestByState(wantedStates) { + for (const req of this._outgoingRoomKeyRequests) { + for (const state of wantedStates) { + if (req.state === state) { + return Promise.resolve(req); + } + } + } + return Promise.resolve(null); + } + + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + const results = []; + + for (const req of this._outgoingRoomKeyRequests) { + for (const state of wantedStates) { + if (req.state === state && req.recipients.includes({userId, deviceId})) { + results.push(req); + } + } + } + return Promise.resolve(results); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * @param {Object} updates name/value map of updates to apply + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + updateOutgoingRoomKeyRequest(requestId, expectedState, updates) { + for (const req of this._outgoingRoomKeyRequests) { + if (req.requestId !== requestId) { + continue; + } + + if (req.state != expectedState) { + logger.warn( + `Cannot update room key request from ${expectedState} ` + + `as it was already updated to ${req.state}`, + ); + return Promise.resolve(null); + } + Object.assign(req, updates); + return Promise.resolve(req); + } + + return Promise.resolve(null); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * + * @returns {Promise} resolves once the operation is completed + */ + deleteOutgoingRoomKeyRequest(requestId, expectedState) { + for (let i = 0; i < this._outgoingRoomKeyRequests.length; i++) { + const req = this._outgoingRoomKeyRequests[i]; + + if (req.requestId !== requestId) { + continue; + } + + if (req.state != expectedState) { + logger.warn( + `Cannot delete room key request in state ${req.state} ` + + `(expected ${expectedState})`, + ); + return Promise.resolve(null); + } + + this._outgoingRoomKeyRequests.splice(i, 1); + return Promise.resolve(req); + } + + return Promise.resolve(null); + } + + // Olm Account + + getAccount(txn, func) { + func(this._account); + } + + storeAccount(txn, newData) { + this._account = newData; + } + + // Olm Sessions + + countEndToEndSessions(txn, func) { + return Object.keys(this._sessions).length; + } + + getEndToEndSession(deviceKey, sessionId, txn, func) { + const deviceSessions = this._sessions[deviceKey] || {}; + func(deviceSessions[sessionId] || null); + } + + getEndToEndSessions(deviceKey, txn, func) { + func(this._sessions[deviceKey] || {}); + } + + getAllEndToEndSessions(txn, func) { + for (const deviceSessions of Object.values(this._sessions)) { + for (const sess of Object.values(deviceSessions)) { + func(sess); + } + } + } + + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { + let deviceSessions = this._sessions[deviceKey]; + if (deviceSessions === undefined) { + deviceSessions = {}; + this._sessions[deviceKey] = deviceSessions; + } + deviceSessions[sessionId] = sessionInfo; + } + + // Inbound Group Sessions + + getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { + func(this._inboundGroupSessions[senderCurve25519Key+'/'+sessionId] || null); + } + + getAllEndToEndInboundGroupSessions(txn, func) { + for (const key of Object.keys(this._inboundGroupSessions)) { + // we can't use split, as the components we are trying to split out + // might themselves contain '/' characters. We rely on the + // senderKey being a (32-byte) curve25519 key, base64-encoded + // (hence 43 characters long). + + func({ + senderKey: key.substr(0, 43), + sessionId: key.substr(44), + sessionData: this._inboundGroupSessions[key], + }); + } + func(null); + } + + addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + const k = senderCurve25519Key+'/'+sessionId; + if (this._inboundGroupSessions[k] === undefined) { + this._inboundGroupSessions[k] = sessionData; + } + } + + storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn) { + this._inboundGroupSessions[senderCurve25519Key+'/'+sessionId] = sessionData; + } + + // Device Data + + getEndToEndDeviceData(txn, func) { + func(this._deviceData); + } + + storeEndToEndDeviceData(deviceData, txn) { + this._deviceData = deviceData; + } + + // E2E rooms + + storeEndToEndRoom(roomId, roomInfo, txn) { + this._rooms[roomId] = roomInfo; + } + + getEndToEndRooms(txn, func) { + func(this._rooms); + } + + getSessionsNeedingBackup(limit) { + const sessions = []; + for (const session in this._sessionsNeedingBackup) { + if (this._inboundGroupSessions[session]) { + sessions.push({ + senderKey: session.substr(0, 43), + sessionId: session.substr(44), + sessionData: this._inboundGroupSessions[session], + }); + if (limit && session.length >= limit) { + break; + } + } + } + return Promise.resolve(sessions); + } + + countSessionsNeedingBackup() { + return Promise.resolve(Object.keys(this._sessionsNeedingBackup).length); + } + + unmarkSessionsNeedingBackup(sessions) { + for (const session of sessions) { + const sessionKey = session.senderKey + '/' + session.sessionId; + delete this._sessionsNeedingBackup[sessionKey]; + } + return Promise.resolve(); + } + + markSessionsNeedingBackup(sessions) { + for (const session of sessions) { + const sessionKey = session.senderKey + '/' + session.sessionId; + this._sessionsNeedingBackup[sessionKey] = true; + } + return Promise.resolve(); + } + + // Session key backups + + doTxn(mode, stores, func) { + return Promise.resolve(func(null)); + } +} diff --git a/matrix-js-sdk/src/crypto/verification/Base.js b/matrix-js-sdk/src/crypto/verification/Base.js new file mode 100644 index 000000000..61e58b10c --- /dev/null +++ b/matrix-js-sdk/src/crypto/verification/Base.js @@ -0,0 +1,266 @@ +/* +Copyright 2018 New Vector 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. +*/ + +/** + * Base class for verification methods. + * @module crypto/verification/Base + */ + +import {MatrixEvent} from '../../models/event'; +import {EventEmitter} from 'events'; +import logger from '../../logger'; +import {newTimeoutError} from "./Error"; + +const timeoutException = new Error("Verification timed out"); + +export default class VerificationBase extends EventEmitter { + /** + * Base class for verification methods. + * + *

    Once a verifier object is created, the verification can be started by + * calling the verify() method, which will return a promise that will + * resolve when the verification is completed, or reject if it could not + * complete.

    + * + *

    Subclasses must have a NAME class property.

    + * + * @class + * + * @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface + * + * @param {string} userId the user ID that is being verified + * + * @param {string} deviceId the device ID that is being verified + * + * @param {string} transactionId the transaction ID to be used when sending events + * + * @param {object} startEvent the m.key.verification.start event that + * initiated this verification, if any + * + * @param {object} request the key verification request object related to + * this verification, if any + * + * @param {object} parent parent verification for this verification, if any + */ + constructor(baseApis, userId, deviceId, transactionId, startEvent, request, parent) { + super(); + this._baseApis = baseApis; + this.userId = userId; + this.deviceId = deviceId; + this.transactionId = transactionId; + this.startEvent = startEvent; + this.request = request; + this.cancelled = false; + this._parent = parent; + this._done = false; + this._promise = null; + this._transactionTimeoutTimer = null; + + // At this point, the verification request was received so start the timeout timer. + this._resetTimer(); + } + + _resetTimer() { + console.log("Refreshing/starting the verification transaction timeout timer"); + if (this._transactionTimeoutTimer !== null) { + clearTimeout(this._transactionTimeoutTimer); + } + this._transactionTimeoutTimer = setTimeout(() => { + if (!this._done && !this.cancelled) { + console.log("Triggering verification timeout"); + this.cancel(timeoutException); + } + }, 10 * 60 * 1000); // 10 minutes + } + + _endTimer() { + if (this._transactionTimeoutTimer !== null) { + clearTimeout(this._transactionTimeoutTimer); + this._transactionTimeoutTimer = null; + } + } + + get initiatedByMe() { + // if there is no start event yet, + // we probably want to send it, + // which happens if we initiate + if (!this.startEvent) { + return true; + } + const sender = this.startEvent.getSender(); + const content = this.startEvent.getContent(); + return sender === this._baseApis.getUserId() && + content.from_device === this._baseApis.getDeviceId(); + } + + _sendToDevice(type, content) { + if (this._done) { + return Promise.reject(new Error("Verification is already done")); + } + content.transaction_id = this.transactionId; + return this._baseApis.sendToDevice(type, { + [this.userId]: { [this.deviceId]: content }, + }); + } + + _waitForEvent(type) { + if (this._done) { + return Promise.reject(new Error("Verification is already done")); + } + this._expectedEvent = type; + return new Promise((resolve, reject) => { + this._resolveEvent = resolve; + this._rejectEvent = reject; + }); + } + + handleEvent(e) { + if (this._done) { + return; + } else if (e.getType() === this._expectedEvent) { + this._expectedEvent = undefined; + this._rejectEvent = undefined; + this._resetTimer(); + this._resolveEvent(e); + } else { + this._expectedEvent = undefined; + const exception = new Error( + "Unexpected message: expecting " + this._expectedEvent + + " but got " + e.getType(), + ); + if (this._rejectEvent) { + const reject = this._rejectEvent; + this._rejectEvent = undefined; + reject(exception); + } + this.cancel(exception); + } + } + + done() { + this._endTimer(); // always kill the activity timer + if (!this._done) { + this._resolve(); + } + } + + cancel(e) { + this._endTimer(); // always kill the activity timer + if (!this._done) { + this.cancelled = true; + if (this.userId && this.deviceId && this.transactionId) { + // send a cancellation to the other user (if it wasn't + // cancelled by the other user) + if (e === timeoutException) { + const timeoutEvent = newTimeoutError(); + this._sendToDevice(timeoutEvent.getType(), timeoutEvent.getContent()); + } else if (e instanceof MatrixEvent) { + const sender = e.getSender(); + if (sender !== this.userId) { + const content = e.getContent(); + if (e.getType() === "m.key.verification.cancel") { + content.code = content.code || "m.unknown"; + content.reason = content.reason || content.body + || "Unknown reason"; + content.transaction_id = this.transactionId; + this._sendToDevice("m.key.verification.cancel", content); + } else { + this._sendToDevice("m.key.verification.cancel", { + code: "m.unknown", + reason: content.body || "Unknown reason", + transaction_id: this.transactionId, + }); + } + } + } else { + this._sendToDevice("m.key.verification.cancel", { + code: "m.unknown", + reason: e.toString(), + transaction_id: this.transactionId, + }); + } + } + if (this._promise !== null) { + // when we cancel without a promise, we end up with a promise + // but no reject function. If cancel is called again, we'd error. + if (this._reject) this._reject(e); + } else { + this._promise = Promise.reject(e); + } + // Also emit a 'cancel' event that the app can listen for to detect cancellation + // before calling verify() + this.emit('cancel', e); + } + } + + /** + * Begin the key verification + * + * @returns {Promise} Promise which resolves when the verification has + * completed. + */ + verify() { + if (this._promise) return this._promise; + + this._promise = new Promise((resolve, reject) => { + this._resolve = (...args) => { + this._done = true; + this._endTimer(); + resolve(...args); + }; + this._reject = (...args) => { + this._done = true; + this._endTimer(); + reject(...args); + }; + }); + if (this._doVerification && !this._started) { + this._started = true; + this._resetTimer(); // restart the timeout + Promise.resolve(this._doVerification()) + .then(this.done.bind(this), this.cancel.bind(this)); + } + return this._promise; + } + + async _verifyKeys(userId, keys, verifier) { + // we try to verify all the keys that we're told about, but we might + // not know about all of them, so keep track of the keys that we know + // about, and ignore the rest + const verifiedDevices = []; + + for (const [keyId, keyInfo] of Object.entries(keys)) { + const deviceId = keyId.split(':', 2)[1]; + const device = await this._baseApis.getStoredDevice(userId, deviceId); + if (!device) { + logger.warn(`verification: Could not find device ${deviceId} to verify`); + } else { + await verifier(keyId, device, keyInfo); + verifiedDevices.push(deviceId); + } + } + + // if none of the keys could be verified, then error because the app + // should be informed about that + if (!verifiedDevices.length) { + throw new Error("No devices could be verified"); + } + + for (const deviceId of verifiedDevices) { + await this._baseApis.setDeviceVerified(userId, deviceId); + } + } +} diff --git a/matrix-js-sdk/src/crypto/verification/Error.js b/matrix-js-sdk/src/crypto/verification/Error.js new file mode 100644 index 000000000..ae761a4d6 --- /dev/null +++ b/matrix-js-sdk/src/crypto/verification/Error.js @@ -0,0 +1,87 @@ +/* +Copyright 2018 New Vector 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. +*/ + +/** + * Error messages. + * + * @module crypto/verification/Error + */ + +import {MatrixEvent} from "../../models/event"; + +export function newVerificationError(code, reason, extradata) { + extradata = extradata || {}; + extradata.code = code; + extradata.reason = reason; + return new MatrixEvent({ + type: "m.key.verification.cancel", + content: extradata, + }); +} + +export function errorFactory(code, reason) { + return function(extradata) { + return newVerificationError(code, reason, extradata); + }; +} + +/** + * The verification was cancelled by the user. + */ +export const newUserCancelledError = errorFactory("m.user", "Cancelled by user"); + +/** + * The verification timed out. + */ +export const newTimeoutError = errorFactory("m.timeout", "Timed out"); + +/** + * The transaction is unknown. + */ +export const newUnknownTransactionError = errorFactory( + "m.unknown_transaction", "Unknown transaction", +); + +/** + * An unknown method was selected. + */ +export const newUnknownMethodError = errorFactory("m.unknown_method", "Unknown method"); + +/** + * An unexpected message was sent. + */ +export const newUnexpectedMessageError = errorFactory( + "m.unexpected_message", "Unexpected message", +); + +/** + * The key does not match. + */ +export const newKeyMismatchError = errorFactory( + "m.key_mismatch", "Key mismatch", +); + +/** + * The user does not match. + */ +export const newUserMismatchError = errorFactory("m.user_error", "User mismatch"); + +/** + * An invalid message was sent. + */ +export const newInvalidMessageError = errorFactory( + "m.invalid_message", "Invalid message", +); diff --git a/matrix-js-sdk/src/crypto/verification/QRCode.js b/matrix-js-sdk/src/crypto/verification/QRCode.js new file mode 100644 index 000000000..d86e389b3 --- /dev/null +++ b/matrix-js-sdk/src/crypto/verification/QRCode.js @@ -0,0 +1,123 @@ +/* +Copyright 2018 New Vector 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. +*/ + +/** + * QR code key verification. + * @module crypto/verification/QRCode + */ + +import Base from "./Base"; +import { + errorFactory, + newUserCancelledError, + newKeyMismatchError, + newUserMismatchError, +} from './Error'; + +const MATRIXTO_REGEXP = /^(?:https?:\/\/)?(?:www\.)?matrix\.to\/#\/([#@!+][^?]+)\?(.+)$/; +const KEY_REGEXP = /^key_([^:]+:.+)$/; + +const newQRCodeError = errorFactory("m.qr_code.invalid", "Invalid QR code"); + +/** + * @class crypto/verification/QRCode/ShowQRCode + * @extends {module:crypto/verification/Base} + */ +export class ShowQRCode extends Base { + _doVerification() { + if (!this._done) { + const url = "https://matrix.to/#/" + this._baseApis.getUserId() + + "?device=" + encodeURIComponent(this._baseApis.deviceId) + + "&action=verify&key_ed25519%3A" + + encodeURIComponent(this._baseApis.deviceId) + "=" + + encodeURIComponent(this._baseApis.getDeviceEd25519Key()); + this.emit("show_qr_code", { + url: url, + }); + } + } +} + +ShowQRCode.NAME = "m.qr_code.show.v1"; + +/** + * @class crypto/verification/QRCode/ScanQRCode + * @extends {module:crypto/verification/Base} + */ +export class ScanQRCode extends Base { + static factory(...args) { + return new ScanQRCode(...args); + } + + async _doVerification() { + const code = await new Promise((resolve, reject) => { + this.emit("scan", { + done: resolve, + cancel: () => reject(newUserCancelledError()), + }); + }); + + const match = code.match(MATRIXTO_REGEXP); + let deviceId; + const keys = {}; + if (!match) { + throw newQRCodeError(); + } + const userId = match[1]; + const params = match[2].split("&").map( + (x) => x.split("=", 2).map(decodeURIComponent), + ); + let action; + for (const [name, value] of params) { + if (name === "device") { + deviceId = value; + } else if (name === "action") { + action = value; + } else { + const keyMatch = name.match(KEY_REGEXP); + if (keyMatch) { + keys[keyMatch[1]] = value; + } + } + } + if (!deviceId || action !== "verify" || Object.keys(keys).length === 0) { + throw newQRCodeError(); + } + + if (!this.userId) { + await new Promise((resolve, reject) => { + this.emit("confirm_user_id", { + userId: userId, + confirm: resolve, + cancel: () => reject(newUserMismatchError()), + }); + }); + } else if (this.userId !== userId) { + throw newUserMismatchError({ + expected: this.userId, + actual: userId, + }); + } + + await this._verifyKeys(userId, keys, (keyId, device, key) => { + if (device.keys[keyId] !== key) { + throw newKeyMismatchError(); + } + }); + } +} + +ScanQRCode.NAME = "m.qr_code.scan.v1"; diff --git a/matrix-js-sdk/src/crypto/verification/SAS.js b/matrix-js-sdk/src/crypto/verification/SAS.js new file mode 100644 index 000000000..9a495f4ad --- /dev/null +++ b/matrix-js-sdk/src/crypto/verification/SAS.js @@ -0,0 +1,418 @@ +/* +Copyright 2018 New Vector 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. +*/ + +/** + * Short Authentication String (SAS) verification. + * @module crypto/verification/SAS + */ + +import Base from "./Base"; +import anotherjson from 'another-json'; +import { + errorFactory, + newUserCancelledError, + newUnknownMethodError, + newKeyMismatchError, + newInvalidMessageError, +} from './Error'; + +const EVENTS = [ + "m.key.verification.accept", + "m.key.verification.key", + "m.key.verification.mac", +]; + +let olmutil; + +const newMismatchedSASError = errorFactory( + "m.mismatched_sas", "Mismatched short authentication string", +); + +const newMismatchedCommitmentError = errorFactory( + "m.mismatched_commitment", "Mismatched commitment", +); + +function generateDecimalSas(sasBytes) { + /** + * +--------+--------+--------+--------+--------+ + * | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | + * +--------+--------+--------+--------+--------+ + * bits: 87654321 87654321 87654321 87654321 87654321 + * \____________/\_____________/\____________/ + * 1st number 2nd number 3rd number + */ + return [ + (sasBytes[0] << 5 | sasBytes[1] >> 3) + 1000, + ((sasBytes[1] & 0x7) << 10 | sasBytes[2] << 2 | sasBytes[3] >> 6) + 1000, + ((sasBytes[3] & 0x3f) << 7 | sasBytes[4] >> 1) + 1000, + ]; +} + +const emojiMapping = [ + ["🐶", "dog"], // 0 + ["🐱", "cat"], // 1 + ["🦁", "lion"], // 2 + ["🐎", "horse"], // 3 + ["🦄", "unicorn"], // 4 + ["🐷", "pig"], // 5 + ["🐘", "elephant"], // 6 + ["🐰", "rabbit"], // 7 + ["🐼", "panda"], // 8 + ["🐓", "rooster"], // 9 + ["🐧", "penguin"], // 10 + ["🐢", "turtle"], // 11 + ["🐟", "fish"], // 12 + ["🐙", "octopus"], // 13 + ["🦋", "butterfly"], // 14 + ["🌷", "flower"], // 15 + ["🌳", "tree"], // 16 + ["🌵", "cactus"], // 17 + ["🍄", "mushroom"], // 18 + ["🌏", "globe"], // 19 + ["🌙", "moon"], // 20 + ["☁️", "cloud"], // 21 + ["🔥", "fire"], // 22 + ["🍌", "banana"], // 23 + ["🍎", "apple"], // 24 + ["🍓", "strawberry"], // 25 + ["🌽", "corn"], // 26 + ["🍕", "pizza"], // 27 + ["🎂", "cake"], // 28 + ["❤️", "heart"], // 29 + ["🙂", "smiley"], // 30 + ["🤖", "robot"], // 31 + ["🎩", "hat"], // 32 + ["👓", "glasses"], // 33 + ["🔧", "spanner"], // 34 + ["🎅", "santa"], // 35 + ["👍", "thumbs up"], // 36 + ["☂️", "umbrella"], // 37 + ["⌛", "hourglass"], // 38 + ["⏰", "clock"], // 39 + ["🎁", "gift"], // 40 + ["💡", "light bulb"], // 41 + ["📕", "book"], // 42 + ["✏️", "pencil"], // 43 + ["📎", "paperclip"], // 44 + ["✂️", "scissors"], // 45 + ["🔒", "padlock"], // 46 + ["🔑", "key"], // 47 + ["🔨", "hammer"], // 48 + ["☎️", "telephone"], // 49 + ["🏁", "flag"], // 50 + ["🚂", "train"], // 51 + ["🚲", "bicycle"], // 52 + ["✈️", "aeroplane"], // 53 + ["🚀", "rocket"], // 54 + ["🏆", "trophy"], // 55 + ["⚽", "ball"], // 56 + ["🎸", "guitar"], // 57 + ["🎺", "trumpet"], // 58 + ["🔔", "bell"], // 59 + ["⚓️", "anchor"], // 60 + ["🎧", "headphones"], // 61 + ["📁", "folder"], // 62 + ["📌", "pin"], // 63 +]; + +function generateEmojiSas(sasBytes) { + const emojis = [ + // just like base64 encoding + sasBytes[0] >> 2, + (sasBytes[0] & 0x3) << 4 | sasBytes[1] >> 4, + (sasBytes[1] & 0xf) << 2 | sasBytes[2] >> 6, + sasBytes[2] & 0x3f, + sasBytes[3] >> 2, + (sasBytes[3] & 0x3) << 4 | sasBytes[4] >> 4, + (sasBytes[4] & 0xf) << 2 | sasBytes[5] >> 6, + ]; + + return emojis.map((num) => emojiMapping[num]); +} + +const sasGenerators = { + decimal: generateDecimalSas, + emoji: generateEmojiSas, +}; + +function generateSas(sasBytes, methods) { + const sas = {}; + for (const method of methods) { + if (method in sasGenerators) { + sas[method] = sasGenerators[method](sasBytes); + } + } + return sas; +} + +const macMethods = { + "hkdf-hmac-sha256": "calculate_mac", + "hmac-sha256": "calculate_mac_long_kdf", +}; + +const calculateKeyAgreement = { + "curve25519-hkdf-sha256": function(sas, olmSAS, bytes) { + const ourInfo = `${sas._baseApis.getUserId()}|${sas._baseApis.deviceId}|` + + `${sas.ourSASPubKey}|`; + const theirInfo = `${sas.userId}|${sas.deviceId}|${sas.theirSASPubKey}|`; + const sasInfo = + "MATRIX_KEY_VERIFICATION_SAS|" + + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + + sas.transactionId; + return olmSAS.generate_bytes(sasInfo, bytes); + }, + "curve25519": function(sas, olmSAS, bytes) { + const ourInfo = `${sas._baseApis.getUserId()}${sas._baseApis.deviceId}`; + const theirInfo = `${sas.userId}${sas.deviceId}`; + const sasInfo = + "MATRIX_KEY_VERIFICATION_SAS" + + (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) + + sas.transactionId; + return olmSAS.generate_bytes(sasInfo, bytes); + }, +}; + +/* lists of algorithms/methods that are supported. The key agreement, hashes, + * and MAC lists should be sorted in order of preference (most preferred + * first). + */ +const KEY_AGREEMENT_LIST = ["curve25519-hkdf-sha256", "curve25519"]; +const HASHES_LIST = ["sha256"]; +const MAC_LIST = ["hkdf-hmac-sha256", "hmac-sha256"]; +const SAS_LIST = Object.keys(sasGenerators); + +const KEY_AGREEMENT_SET = new Set(KEY_AGREEMENT_LIST); +const HASHES_SET = new Set(HASHES_LIST); +const MAC_SET = new Set(MAC_LIST); +const SAS_SET = new Set(SAS_LIST); + +function intersection(anArray, aSet) { + return anArray instanceof Array ? anArray.filter(x => aSet.has(x)) : []; +} + +/** + * @alias module:crypto/verification/SAS + * @extends {module:crypto/verification/Base} + */ +export default class SAS extends Base { + get events() { + return EVENTS; + } + + async _doVerification() { + await global.Olm.init(); + olmutil = olmutil || new global.Olm.Utility(); + + // make sure user's keys are downloaded + await this._baseApis.downloadKeys([this.userId]); + + if (this.startEvent) { + return await this._doRespondVerification(); + } else { + return await this._doSendVerification(); + } + } + + async _doSendVerification() { + const initialMessage = { + method: SAS.NAME, + from_device: this._baseApis.deviceId, + key_agreement_protocols: KEY_AGREEMENT_LIST, + hashes: HASHES_LIST, + message_authentication_codes: MAC_LIST, + // FIXME: allow app to specify what SAS methods can be used + short_authentication_string: SAS_LIST, + transaction_id: this.transactionId, + }; + this._sendToDevice("m.key.verification.start", initialMessage); + + + let e = await this._waitForEvent("m.key.verification.accept"); + let content = e.getContent(); + const sasMethods + = intersection(content.short_authentication_string, SAS_SET); + if (!(KEY_AGREEMENT_SET.has(content.key_agreement_protocol) + && HASHES_SET.has(content.hash) + && MAC_SET.has(content.message_authentication_code) + && sasMethods.length)) { + throw newUnknownMethodError(); + } + if (typeof content.commitment !== "string") { + throw newInvalidMessageError(); + } + const keyAgreement = content.key_agreement_protocol; + const macMethod = content.message_authentication_code; + const hashCommitment = content.commitment; + const olmSAS = new global.Olm.SAS(); + try { + this.ourSASPubKey = olmSAS.get_pubkey(); + this._sendToDevice("m.key.verification.key", { + key: this.ourSASPubKey, + }); + + + e = await this._waitForEvent("m.key.verification.key"); + // FIXME: make sure event is properly formed + content = e.getContent(); + const commitmentStr = content.key + anotherjson.stringify(initialMessage); + // TODO: use selected hash function (when we support multiple) + if (olmutil.sha256(commitmentStr) !== hashCommitment) { + throw newMismatchedCommitmentError(); + } + this.theirSASPubKey = content.key; + olmSAS.set_their_key(content.key); + + const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); + const verifySAS = new Promise((resolve, reject) => { + this.emit("show_sas", { + sas: generateSas(sasBytes, sasMethods), + confirm: () => { + this._sendMAC(olmSAS, macMethod); + resolve(); + }, + cancel: () => reject(newUserCancelledError()), + mismatch: () => reject(newMismatchedSASError()), + }); + }); + + + [e] = await Promise.all([ + this._waitForEvent("m.key.verification.mac"), + verifySAS, + ]); + content = e.getContent(); + await this._checkMAC(olmSAS, content, macMethod); + } finally { + olmSAS.free(); + } + } + + async _doRespondVerification() { + let content = this.startEvent.getContent(); + // Note: we intersect using our pre-made lists, rather than the sets, + // so that the result will be in our order of preference. Then + // fetching the first element from the array will give our preferred + // method out of the ones offered by the other party. + const keyAgreement + = intersection( + KEY_AGREEMENT_LIST, new Set(content.key_agreement_protocols), + )[0]; + const hashMethod + = intersection(HASHES_LIST, new Set(content.hashes))[0]; + const macMethod + = intersection(MAC_LIST, new Set(content.message_authentication_codes))[0]; + // FIXME: allow app to specify what SAS methods can be used + const sasMethods + = intersection(content.short_authentication_string, SAS_SET); + if (!(keyAgreement !== undefined + && hashMethod !== undefined + && macMethod !== undefined + && sasMethods.length)) { + throw newUnknownMethodError(); + } + + const olmSAS = new global.Olm.SAS(); + try { + const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(content); + this._sendToDevice("m.key.verification.accept", { + key_agreement_protocol: keyAgreement, + hash: hashMethod, + message_authentication_code: macMethod, + short_authentication_string: sasMethods, + // TODO: use selected hash function (when we support multiple) + commitment: olmutil.sha256(commitmentStr), + }); + + + let e = await this._waitForEvent("m.key.verification.key"); + // FIXME: make sure event is properly formed + content = e.getContent(); + this.theirSASPubKey = content.key; + olmSAS.set_their_key(content.key); + this.ourSASPubKey = olmSAS.get_pubkey(); + this._sendToDevice("m.key.verification.key", { + key: this.ourSASPubKey, + }); + + const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); + const verifySAS = new Promise((resolve, reject) => { + this.emit("show_sas", { + sas: generateSas(sasBytes, sasMethods), + confirm: () => { + this._sendMAC(olmSAS, macMethod); + resolve(); + }, + cancel: () => reject(newUserCancelledError()), + mismatch: () => reject(newMismatchedSASError()), + }); + }); + + + [e] = await Promise.all([ + this._waitForEvent("m.key.verification.mac"), + verifySAS, + ]); + content = e.getContent(); + await this._checkMAC(olmSAS, content, macMethod); + } finally { + olmSAS.free(); + } + } + + _sendMAC(olmSAS, method) { + const keyId = `ed25519:${this._baseApis.deviceId}`; + const mac = {}; + const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + + this._baseApis.getUserId() + this._baseApis.deviceId + + this.userId + this.deviceId + + this.transactionId; + + mac[keyId] = olmSAS[macMethods[method]]( + this._baseApis.getDeviceEd25519Key(), + baseInfo + keyId, + ); + const keys = olmSAS[macMethods[method]]( + keyId, + baseInfo + "KEY_IDS", + ); + this._sendToDevice("m.key.verification.mac", { mac, keys }); + } + + async _checkMAC(olmSAS, content, method) { + const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + + this.userId + this.deviceId + + this._baseApis.getUserId() + this._baseApis.deviceId + + this.transactionId; + + if (content.keys !== olmSAS[macMethods[method]]( + Object.keys(content.mac).sort().join(","), + baseInfo + "KEY_IDS", + )) { + throw newKeyMismatchError(); + } + + await this._verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => { + if (keyInfo !== olmSAS[macMethods[method]]( + device.keys[keyId], + baseInfo + keyId, + )) { + throw newKeyMismatchError(); + } + }); + } +} + +SAS.NAME = "m.sas.v1"; diff --git a/matrix-js-sdk/src/errors.js b/matrix-js-sdk/src/errors.js new file mode 100644 index 000000000..f3d88d643 --- /dev/null +++ b/matrix-js-sdk/src/errors.js @@ -0,0 +1,46 @@ +// can't just do InvalidStoreError extends Error +// because of http://babeljs.io/docs/usage/caveats/#classes +export function InvalidStoreError(reason, value) { + const message = `Store is invalid because ${reason}, ` + + `please stop the client, delete all data and start the client again`; + const instance = Reflect.construct(Error, [message]); + Reflect.setPrototypeOf(instance, Reflect.getPrototypeOf(this)); + instance.reason = reason; + instance.value = value; + return instance; +} + +InvalidStoreError.TOGGLED_LAZY_LOADING = "TOGGLED_LAZY_LOADING"; + +InvalidStoreError.prototype = Object.create(Error.prototype, { + constructor: { + value: Error, + enumerable: false, + writable: true, + configurable: true, + }, +}); +Reflect.setPrototypeOf(InvalidStoreError, Error); + + +export function InvalidCryptoStoreError(reason) { + const message = `Crypto store is invalid because ${reason}, ` + + `please stop the client, delete all data and start the client again`; + const instance = Reflect.construct(Error, [message]); + Reflect.setPrototypeOf(instance, Reflect.getPrototypeOf(this)); + instance.reason = reason; + instance.name = 'InvalidCryptoStoreError'; + return instance; +} + +InvalidCryptoStoreError.TOO_NEW = "TOO_NEW"; + +InvalidCryptoStoreError.prototype = Object.create(Error.prototype, { + constructor: { + value: Error, + enumerable: false, + writable: true, + configurable: true, + }, +}); +Reflect.setPrototypeOf(InvalidCryptoStoreError, Error); diff --git a/matrix-js-sdk/src/filter-component.js b/matrix-js-sdk/src/filter-component.js new file mode 100644 index 000000000..38b1c8afd --- /dev/null +++ b/matrix-js-sdk/src/filter-component.js @@ -0,0 +1,146 @@ +/* +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. +*/ +"use strict"; +/** + * @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 } + */ +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) { + if (!allowed_values.map(match_func)) { + 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; +}; + +/** The FilterComponent class */ +module.exports = FilterComponent; diff --git a/matrix-js-sdk/src/filter.js b/matrix-js-sdk/src/filter.js new file mode 100644 index 000000000..a63fcee13 --- /dev/null +++ b/matrix-js-sdk/src/filter.js @@ -0,0 +1,203 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; +/** + * @module filter + */ + +const FilterComponent = require("./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 + */ +function Filter(userId, filterId) { + this.userId = userId; + this.filterId = filterId; + this.definition = {}; +} + +Filter.LAZY_LOADING_MESSAGES_FILTER = { + lazy_load_members: true, +}; + +Filter.LAZY_LOADING_SYNC_FILTER = { + room: { + state: Filter.LAZY_LOADING_MESSAGES_FILTER, + }, +}; + + +/** + * 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"], + // }, + // "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); +}; + +/** + * 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; +}; + +/** The Filter class */ +module.exports = Filter; diff --git a/matrix-js-sdk/src/http-api.js b/matrix-js-sdk/src/http-api.js new file mode 100644 index 000000000..5731a3e34 --- /dev/null +++ b/matrix-js-sdk/src/http-api.js @@ -0,0 +1,950 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; +/** + * This is an internal module. See {@link MatrixHttpApi} for the public class. + * @module http-api + */ +import Promise from 'bluebird'; +const parseContentType = require('content-type').parse; + +const utils = require("./utils"); +import logger from '../src/logger'; + +// we use our own implementation of setTimeout, so that if we get suspended in +// the middle of a /sync, we cancel the sync as soon as we awake, rather than +// waiting for the delay to elapse. +const callbacks = require("./realtime-callbacks"); + +/* +TODO: +- CS: complete register function (doing stages) +- Identity server: linkEmail, authEmail, bindEmail, lookup3pid +*/ + +/** + * A constant representing the URI path for release 0 of the Client-Server HTTP API. + */ +module.exports.PREFIX_R0 = "/_matrix/client/r0"; + +/** + * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs. + */ +module.exports.PREFIX_UNSTABLE = "/_matrix/client/unstable"; + +/** + * URI path for the identity API + */ +module.exports.PREFIX_IDENTITY_V1 = "/_matrix/identity/api/v1"; + +/** + * URI path for the media repo API + */ +module.exports.PREFIX_MEDIA_R0 = "/_matrix/media/r0"; + +/** + * Construct a MatrixHttpApi. + * @constructor + * @param {EventEmitter} event_emitter The event emitter to use for emitting events + * @param {Object} opts The options to use for this HTTP API. + * @param {string} opts.baseUrl Required. The base client-server URL e.g. + * 'http://localhost:8008'. + * @param {Function} opts.request Required. The function to call for HTTP + * requests. This function must look like function(opts, callback){ ... }. + * @param {string} opts.prefix Required. The matrix client prefix to use, e.g. + * '/_matrix/client/r0'. See PREFIX_R0 and PREFIX_UNSTABLE for constants. + * + * @param {boolean} opts.onlyData True to return only the 'data' component of the + * response (e.g. the parsed HTTP body). If false, requests will return an + * object with the properties code, headers and data. + * + * @param {string} opts.accessToken The access_token to send with requests. Can be + * null to not send an access token. + * @param {Object=} opts.extraParams Optional. Extra query parameters to send on + * requests. + * @param {Number=} opts.localTimeoutMs The default maximum amount of time to wait + * before timing out the request. If not specified, there is no timeout. + * @param {boolean} [opts.useAuthorizationHeader = false] Set to true to use + * Authorization header instead of query param to send the access token to the server. + */ +module.exports.MatrixHttpApi = function MatrixHttpApi(event_emitter, opts) { + utils.checkObjectHasKeys(opts, ["baseUrl", "request", "prefix"]); + opts.onlyData = opts.onlyData || false; + this.event_emitter = event_emitter; + this.opts = opts; + this.useAuthorizationHeader = Boolean(opts.useAuthorizationHeader); + this.uploads = []; +}; + +module.exports.MatrixHttpApi.prototype = { + + /** + * Get the content repository url with query parameters. + * @return {Object} An object with a 'base', 'path' and 'params' for base URL, + * path and query parameters respectively. + */ + getContentUri: function() { + const params = { + access_token: this.opts.accessToken, + }; + return { + base: this.opts.baseUrl, + path: "/_matrix/media/r0/upload", + params: params, + }; + }, + + /** + * Upload content to the Home Server + * + * @param {object} file The object to upload. On a browser, something that + * can be sent to XMLHttpRequest.send (typically a File). Under node.js, + * a Buffer, String or ReadStream. + * + * @param {object} opts options object + * + * @param {string=} opts.name Name to give the file on the server. Defaults + * to file.name. + * + * @param {boolean=} opts.includeFilename if false will not send the filename, + * e.g for encrypted file uploads where filename leaks are undesirable. + * Defaults to true. + * + * @param {string=} opts.type Content-type for the upload. Defaults to + * file.type, or applicaton/octet-stream. + * + * @param {boolean=} opts.rawResponse Return the raw body, rather than + * parsing the JSON. Defaults to false (except on node.js, where it + * defaults to true for backwards compatibility). + * + * @param {boolean=} opts.onlyContentUri Just return the content URI, + * rather than the whole body. Defaults to false (except on browsers, + * where it defaults to true for backwards compatibility). Ignored if + * opts.rawResponse is true. + * + * @param {Function=} opts.callback Deprecated. Optional. The callback to + * invoke on success/failure. See the promise return values for more + * information. + * + * @param {Function=} opts.progressHandler Optional. Called when a chunk of + * data has been uploaded, with an object containing the fields `loaded` + * (number of bytes transferred) and `total` (total size, if known). + * + * @return {module:client.Promise} Resolves to response object, as + * determined by this.opts.onlyData, opts.rawResponse, and + * opts.onlyContentUri. Rejects with an error (usually a MatrixError). + */ + uploadContent: function(file, opts) { + if (utils.isFunction(opts)) { + // opts used to be callback + opts = { + callback: opts, + }; + } else if (opts === undefined) { + opts = {}; + } + + // default opts.includeFilename to true (ignoring falsey values) + const includeFilename = opts.includeFilename !== false; + + // if the file doesn't have a mime type, use a default since + // the HS errors if we don't supply one. + const contentType = opts.type || file.type || 'application/octet-stream'; + const fileName = opts.name || file.name; + + // We used to recommend setting file.stream to the thing to upload on + // Node.js. As of 2019-06-11, this is still in widespread use in various + // clients, so we should preserve this for simple objects used in + // Node.js. File API objects (via either the File or Blob interfaces) in + // the browser now define a `stream` method, which leads to trouble + // here, so we also check the type of `stream`. + let body = file; + if (body.stream && typeof body.stream !== "function") { + logger.warn( + "Using `file.stream` as the content to upload. Future " + + "versions of the js-sdk will change this to expect `file` to " + + "be the content directly.", + ); + body = body.stream; + } + + // backwards-compatibility hacks where we used to do different things + // between browser and node. + let rawResponse = opts.rawResponse; + if (rawResponse === undefined) { + if (global.XMLHttpRequest) { + rawResponse = false; + } else { + logger.warn( + "Returning the raw JSON from uploadContent(). Future " + + "versions of the js-sdk will change this default, to " + + "return the parsed object. Set opts.rawResponse=false " + + "to change this behaviour now.", + ); + rawResponse = true; + } + } + + let onlyContentUri = opts.onlyContentUri; + if (!rawResponse && onlyContentUri === undefined) { + if (global.XMLHttpRequest) { + logger.warn( + "Returning only the content-uri from uploadContent(). " + + "Future versions of the js-sdk will change this " + + "default, to return the whole response object. Set " + + "opts.onlyContentUri=false to change this behaviour now.", + ); + onlyContentUri = true; + } else { + onlyContentUri = false; + } + } + + // browser-request doesn't support File objects because it deep-copies + // the options using JSON.parse(JSON.stringify(options)). Instead of + // loading the whole file into memory as a string and letting + // browser-request base64 encode and then decode it again, we just + // use XMLHttpRequest directly. + // (browser-request doesn't support progress either, which is also kind + // of important here) + + const upload = { loaded: 0, total: 0 }; + let promise; + + // XMLHttpRequest doesn't parse JSON for us. request normally does, but + // we're setting opts.json=false so that it doesn't JSON-encode the + // request, which also means it doesn't JSON-decode the response. Either + // way, we have to JSON-parse the response ourselves. + let bodyParser = null; + if (!rawResponse) { + bodyParser = function(rawBody) { + let body = JSON.parse(rawBody); + if (onlyContentUri) { + body = body.content_uri; + if (body === undefined) { + throw Error('Bad response'); + } + } + return body; + }; + } + + if (global.XMLHttpRequest) { + const defer = Promise.defer(); + const xhr = new global.XMLHttpRequest(); + upload.xhr = xhr; + const cb = requestCallback(defer, opts.callback, this.opts.onlyData); + + const timeout_fn = function() { + xhr.abort(); + cb(new Error('Timeout')); + }; + + // set an initial timeout of 30s; we'll advance it each time we get + // a progress notification + xhr.timeout_timer = callbacks.setTimeout(timeout_fn, 30000); + + xhr.onreadystatechange = function() { + switch (xhr.readyState) { + case global.XMLHttpRequest.DONE: + callbacks.clearTimeout(xhr.timeout_timer); + var resp; + try { + if (!xhr.responseText) { + throw new Error('No response body.'); + } + resp = xhr.responseText; + if (bodyParser) { + resp = bodyParser(resp); + } + } catch (err) { + err.http_status = xhr.status; + cb(err); + return; + } + cb(undefined, xhr, resp); + break; + } + }; + xhr.upload.addEventListener("progress", function(ev) { + callbacks.clearTimeout(xhr.timeout_timer); + upload.loaded = ev.loaded; + upload.total = ev.total; + xhr.timeout_timer = callbacks.setTimeout(timeout_fn, 30000); + if (opts.progressHandler) { + opts.progressHandler({ + loaded: ev.loaded, + total: ev.total, + }); + } + }); + let url = this.opts.baseUrl + "/_matrix/media/r0/upload"; + + const queryArgs = []; + + if (includeFilename && fileName) { + queryArgs.push("filename=" + encodeURIComponent(fileName)); + } + + if (!this.useAuthorizationHeader) { + queryArgs.push("access_token=" + + encodeURIComponent(this.opts.accessToken)); + } + + if (queryArgs.length > 0) { + url += "?" + queryArgs.join("&"); + } + + xhr.open("POST", url); + if (this.useAuthorizationHeader) { + xhr.setRequestHeader("Authorization", "Bearer " + this.opts.accessToken); + } + xhr.setRequestHeader("Content-Type", contentType); + xhr.send(body); + promise = defer.promise; + + // dirty hack (as per _request) to allow the upload to be cancelled. + promise.abort = xhr.abort.bind(xhr); + } else { + const queryParams = {}; + + if (includeFilename && fileName) { + queryParams.filename = fileName; + } + + promise = this.authedRequest( + opts.callback, "POST", "/upload", queryParams, body, { + prefix: "/_matrix/media/r0", + headers: {"Content-Type": contentType}, + json: false, + bodyParser: bodyParser, + }, + ); + } + + const self = this; + + // remove the upload from the list on completion + const promise0 = promise.finally(function() { + for (let i = 0; i < self.uploads.length; ++i) { + if (self.uploads[i] === upload) { + self.uploads.splice(i, 1); + return; + } + } + }); + + // copy our dirty abort() method to the new promise + promise0.abort = promise.abort; + + upload.promise = promise0; + this.uploads.push(upload); + + return promise0; + }, + + cancelUpload: function(promise) { + if (promise.abort) { + promise.abort(); + return true; + } + return false; + }, + + getCurrentUploads: function() { + return this.uploads; + }, + + idServerRequest: function(callback, method, path, params, prefix) { + const fullUri = this.opts.idBaseUrl + prefix + path; + + if (callback !== undefined && !utils.isFunction(callback)) { + throw Error( + "Expected callback to be a function but got " + typeof callback, + ); + } + + const opts = { + uri: fullUri, + method: method, + withCredentials: false, + json: false, + _matrix_opts: this.opts, + }; + if (method == 'GET') { + opts.qs = params; + } else { + opts.form = params; + } + + const defer = Promise.defer(); + this.opts.request( + opts, + requestCallback(defer, callback, this.opts.onlyData), + ); + // ID server does not always take JSON, so we can't use requests' 'json' + // option as we do with the home server, but it does return JSON, so + // parse it manually + return defer.promise.then(function(response) { + return JSON.parse(response); + }); + }, + + /** + * Perform an authorised request to the homeserver. + * @param {Function} callback Optional. The callback to invoke on + * success/failure. See the promise return values for more information. + * @param {string} method The HTTP method e.g. "GET". + * @param {string} path The HTTP path after the supplied prefix e.g. + * "/createRoom". + * + * @param {Object=} queryParams A dict of query params (these will NOT be + * urlencoded). If unspecified, there will be no query params. + * + * @param {Object} data The HTTP JSON body. + * + * @param {Object|Number=} opts additional options. If a number is specified, + * this is treated as `opts.localTimeoutMs`. + * + * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before + * timing out the request. If not specified, there is no timeout. + * + * @param {sting=} opts.prefix The full prefix to use e.g. + * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. + * + * @param {Object=} opts.headers map of additional request headers + * + * @return {module:client.Promise} Resolves to {data: {Object}, + * headers: {Object}, code: {Number}}. + * If onlyData is set, this will resolve to the data + * object only. + * @return {module:http-api.MatrixError} Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + */ + authedRequest: function(callback, method, path, queryParams, data, opts) { + if (!queryParams) { + queryParams = {}; + } + if (this.useAuthorizationHeader) { + if (isFinite(opts)) { + // opts used to be localTimeoutMs + opts = { + localTimeoutMs: opts, + }; + } + if (!opts) { + opts = {}; + } + if (!opts.headers) { + opts.headers = {}; + } + if (!opts.headers.Authorization) { + opts.headers.Authorization = "Bearer " + this.opts.accessToken; + } + if (queryParams.access_token) { + delete queryParams.access_token; + } + } else { + if (!queryParams.access_token) { + queryParams.access_token = this.opts.accessToken; + } + } + + const requestPromise = this.request( + callback, method, path, queryParams, data, opts, + ); + + const self = this; + requestPromise.catch(function(err) { + if (err.errcode == 'M_UNKNOWN_TOKEN') { + self.event_emitter.emit("Session.logged_out", err); + } else if (err.errcode == 'M_CONSENT_NOT_GIVEN') { + self.event_emitter.emit( + "no_consent", + err.message, + err.data.consent_uri, + ); + } + }); + + // return the original promise, otherwise tests break due to it having to + // go around the event loop one more time to process the result of the request + return requestPromise; + }, + + /** + * Perform a request to the homeserver without any credentials. + * @param {Function} callback Optional. The callback to invoke on + * success/failure. See the promise return values for more information. + * @param {string} method The HTTP method e.g. "GET". + * @param {string} path The HTTP path after the supplied prefix e.g. + * "/createRoom". + * + * @param {Object=} queryParams A dict of query params (these will NOT be + * urlencoded). If unspecified, there will be no query params. + * + * @param {Object} data The HTTP JSON body. + * + * @param {Object=} opts additional options + * + * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before + * timing out the request. If not specified, there is no timeout. + * + * @param {sting=} opts.prefix The full prefix to use e.g. + * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. + * + * @param {Object=} opts.headers map of additional request headers + * + * @return {module:client.Promise} Resolves to {data: {Object}, + * headers: {Object}, code: {Number}}. + * If onlyData is set, this will resolve to the data + * object only. + * @return {module:http-api.MatrixError} Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + */ + request: function(callback, method, path, queryParams, data, opts) { + opts = opts || {}; + const prefix = opts.prefix !== undefined ? opts.prefix : this.opts.prefix; + const fullUri = this.opts.baseUrl + prefix + path; + + return this.requestOtherUrl( + callback, method, fullUri, queryParams, data, opts, + ); + }, + + /** + * Perform an authorised request to the homeserver with a specific path + * prefix which overrides the default for this call only. Useful for hitting + * different Matrix Client-Server versions. + * @param {Function} callback Optional. The callback to invoke on + * success/failure. See the promise return values for more information. + * @param {string} method The HTTP method e.g. "GET". + * @param {string} path The HTTP path after the supplied prefix e.g. + * "/createRoom". + * @param {Object} queryParams A dict of query params (these will NOT be + * urlencoded). + * @param {Object} data The HTTP JSON body. + * @param {string} prefix The full prefix to use e.g. + * "/_matrix/client/v2_alpha". + * @param {Number=} localTimeoutMs The maximum amount of time to wait before + * timing out the request. If not specified, there is no timeout. + * @return {module:client.Promise} Resolves to {data: {Object}, + * headers: {Object}, code: {Number}}. + * If onlyData is set, this will resolve to the data + * object only. + * @return {module:http-api.MatrixError} Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + * + * @deprecated prefer authedRequest with opts.prefix + */ + authedRequestWithPrefix: function(callback, method, path, queryParams, data, + prefix, localTimeoutMs) { + return this.authedRequest( + callback, method, path, queryParams, data, { + localTimeoutMs: localTimeoutMs, + prefix: prefix, + }, + ); + }, + + /** + * Perform a request to the homeserver without any credentials but with a + * specific path prefix which overrides the default for this call only. + * Useful for hitting different Matrix Client-Server versions. + * @param {Function} callback Optional. The callback to invoke on + * success/failure. See the promise return values for more information. + * @param {string} method The HTTP method e.g. "GET". + * @param {string} path The HTTP path after the supplied prefix e.g. + * "/createRoom". + * @param {Object} queryParams A dict of query params (these will NOT be + * urlencoded). + * @param {Object} data The HTTP JSON body. + * @param {string} prefix The full prefix to use e.g. + * "/_matrix/client/v2_alpha". + * @param {Number=} localTimeoutMs The maximum amount of time to wait before + * timing out the request. If not specified, there is no timeout. + * @return {module:client.Promise} Resolves to {data: {Object}, + * headers: {Object}, code: {Number}}. + * If onlyData is set, this will resolve to the data + * object only. + * @return {module:http-api.MatrixError} Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + * + * @deprecated prefer request with opts.prefix + */ + requestWithPrefix: function(callback, method, path, queryParams, data, prefix, + localTimeoutMs) { + return this.request( + callback, method, path, queryParams, data, { + localTimeoutMs: localTimeoutMs, + prefix: prefix, + }, + ); + }, + + /** + * Perform a request to an arbitrary URL. + * @param {Function} callback Optional. The callback to invoke on + * success/failure. See the promise return values for more information. + * @param {string} method The HTTP method e.g. "GET". + * @param {string} uri The HTTP URI + * + * @param {Object=} queryParams A dict of query params (these will NOT be + * urlencoded). If unspecified, there will be no query params. + * + * @param {Object} data The HTTP JSON body. + * + * @param {Object=} opts additional options + * + * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before + * timing out the request. If not specified, there is no timeout. + * + * @param {sting=} opts.prefix The full prefix to use e.g. + * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix. + * + * @param {Object=} opts.headers map of additional request headers + * + * @return {module:client.Promise} Resolves to {data: {Object}, + * headers: {Object}, code: {Number}}. + * If onlyData is set, this will resolve to the data + * object only. + * @return {module:http-api.MatrixError} Rejects with an error if a problem + * occurred. This includes network problems and Matrix-specific error JSON. + */ + requestOtherUrl: function(callback, method, uri, queryParams, data, + opts) { + if (opts === undefined || opts === null) { + opts = {}; + } else if (isFinite(opts)) { + // opts used to be localTimeoutMs + opts = { + localTimeoutMs: opts, + }; + } + + return this._request( + callback, method, uri, queryParams, data, opts, + ); + }, + + /** + * Form and return a homeserver request URL based on the given path + * params and prefix. + * @param {string} path The HTTP path after the supplied prefix e.g. + * "/createRoom". + * @param {Object} queryParams A dict of query params (these will NOT be + * urlencoded). + * @param {string} prefix The full prefix to use e.g. + * "/_matrix/client/v2_alpha". + * @return {string} URL + */ + getUrl: function(path, queryParams, prefix) { + let queryString = ""; + if (queryParams) { + queryString = "?" + utils.encodeParams(queryParams); + } + return this.opts.baseUrl + prefix + path + queryString; + }, + + /** + * @private + * + * @param {function} callback + * @param {string} method + * @param {string} uri + * @param {object} queryParams + * @param {object|string} data + * @param {object=} opts + * + * @param {boolean} [opts.json =true] Json-encode data before sending, and + * decode response on receipt. (We will still json-decode error + * responses, even if this is false.) + * + * @param {object=} opts.headers extra request headers + * + * @param {number=} opts.localTimeoutMs client-side timeout for the + * request. Default timeout if falsy. + * + * @param {function=} opts.bodyParser function to parse the body of the + * response before passing it to the promise and callback. + * + * @return {module:client.Promise} a promise which resolves to either the + * response object (if this.opts.onlyData is truthy), or the parsed + * body. Rejects + */ + _request: function(callback, method, uri, queryParams, data, opts) { + if (callback !== undefined && !utils.isFunction(callback)) { + throw Error( + "Expected callback to be a function but got " + typeof callback, + ); + } + opts = opts || {}; + + const self = this; + if (this.opts.extraParams) { + for (const key in this.opts.extraParams) { + if (!this.opts.extraParams.hasOwnProperty(key)) { + continue; + } + queryParams[key] = this.opts.extraParams[key]; + } + } + + const headers = utils.extend({}, opts.headers || {}); + const json = opts.json === undefined ? true : opts.json; + let bodyParser = opts.bodyParser; + + // we handle the json encoding/decoding here, because request and + // browser-request make a mess of it. Specifically, they attempt to + // json-decode plain-text error responses, which in turn means that the + // actual error gets swallowed by a SyntaxError. + + if (json) { + if (data) { + data = JSON.stringify(data); + headers['content-type'] = 'application/json'; + } + + if (!headers['accept']) { + headers['accept'] = 'application/json'; + } + + if (bodyParser === undefined) { + bodyParser = function(rawBody) { + return JSON.parse(rawBody); + }; + } + } + + const defer = Promise.defer(); + + let timeoutId; + let timedOut = false; + let req; + const localTimeoutMs = opts.localTimeoutMs || this.opts.localTimeoutMs; + + const resetTimeout = () => { + if (localTimeoutMs) { + if (timeoutId) { + callbacks.clearTimeout(timeoutId); + } + timeoutId = callbacks.setTimeout(function() { + timedOut = true; + if (req && req.abort) { + req.abort(); + } + defer.reject(new module.exports.MatrixError({ + error: "Locally timed out waiting for a response", + errcode: "ORG.MATRIX.JSSDK_TIMEOUT", + timeout: localTimeoutMs, + })); + }, localTimeoutMs); + } + }; + resetTimeout(); + + const reqPromise = defer.promise; + + try { + req = this.opts.request( + { + uri: uri, + method: method, + withCredentials: false, + qs: queryParams, + qsStringifyOptions: opts.qsStringifyOptions, + useQuerystring: true, + body: data, + json: false, + timeout: localTimeoutMs, + headers: headers || {}, + _matrix_opts: this.opts, + }, + function(err, response, body) { + if (localTimeoutMs) { + callbacks.clearTimeout(timeoutId); + if (timedOut) { + return; // already rejected promise + } + } + + const handlerFn = requestCallback( + defer, callback, self.opts.onlyData, + bodyParser, + ); + handlerFn(err, response, body); + }, + ); + if (req) { + // This will only work in a browser, where opts.request is the + // `browser-request` import. Currently `request` does not support progress + // updates - see https://github.com/request/request/pull/2346. + // `browser-request` returns an XHRHttpRequest which exposes `onprogress` + if ('onprogress' in req) { + req.onprogress = (e) => { + // Prevent the timeout from rejecting the deferred promise if progress is + // seen with the request + resetTimeout(); + }; + } + + // FIXME: This is EVIL, but I can't think of a better way to expose + // abort() operations on underlying HTTP requests :( + if (req.abort) reqPromise.abort = req.abort.bind(req); + } + } catch (ex) { + defer.reject(ex); + if (callback) { + callback(ex); + } + } + return reqPromise; + }, +}; + +/* + * Returns a callback that can be invoked by an HTTP request on completion, + * that will either resolve or reject the given defer as well as invoke the + * given userDefinedCallback (if any). + * + * HTTP errors are transformed into javascript errors and the deferred is rejected. + * + * If bodyParser is given, it is used to transform the body of the successful + * responses before passing to the defer/callback. + * + * If onlyData is true, the defer/callback is invoked with the body of the + * response, otherwise the result object (with `code` and `data` fields) + * + */ +const requestCallback = function( + defer, userDefinedCallback, onlyData, + bodyParser, +) { + userDefinedCallback = userDefinedCallback || function() {}; + + return function(err, response, body) { + if (!err) { + try { + if (response.statusCode >= 400) { + err = parseErrorResponse(response, body); + } else if (bodyParser) { + body = bodyParser(body); + } + } catch (e) { + err = new Error(`Error parsing server response: ${e}`); + } + } + + if (err) { + defer.reject(err); + userDefinedCallback(err); + } else { + const res = { + code: response.statusCode, + + // XXX: why do we bother with this? it doesn't work for + // XMLHttpRequest, so clearly we don't use it. + headers: response.headers, + data: body, + }; + defer.resolve(onlyData ? body : res); + userDefinedCallback(null, onlyData ? body : res); + } + }; +}; + +/** + * Attempt to turn an HTTP error response into a Javascript Error. + * + * If it is a JSON response, we will parse it into a MatrixError. Otherwise + * we return a generic Error. + * + * @param {XMLHttpRequest|http.IncomingMessage} response response object + * @param {String} body raw body of the response + * @returns {Error} + */ +function parseErrorResponse(response, body) { + const httpStatus = response.statusCode; + const contentType = getResponseContentType(response); + + let err; + if (contentType) { + if (contentType.type === 'application/json') { + err = new module.exports.MatrixError(JSON.parse(body)); + } else if (contentType.type === 'text/plain') { + err = new Error(`Server returned ${httpStatus} error: ${body}`); + } + } + + if (!err) { + err = new Error(`Server returned ${httpStatus} error`); + } + err.httpStatus = httpStatus; + return err; +} + + +/** + * extract the Content-Type header from the response object, and + * parse it to a `{type, parameters}` object. + * + * returns null if no content-type header could be found. + * + * @param {XMLHttpRequest|http.IncomingMessage} response response object + * @returns {{type: String, parameters: Object}?} parsed content-type header, or null if not found + */ +function getResponseContentType(response) { + let contentType; + if (response.getResponseHeader) { + // XMLHttpRequest provides getResponseHeader + contentType = response.getResponseHeader("Content-Type"); + } else if (response.headers) { + // request provides http.IncomingMessage which has a message.headers map + contentType = response.headers['content-type'] || null; + } + + if (!contentType) { + return null; + } + + try { + return parseContentType(contentType); + } catch(e) { + throw new Error(`Error parsing Content-Type '${contentType}': ${e}`); + } +} + +/** + * Construct a Matrix error. This is a JavaScript Error with additional + * information specific to the standard Matrix error response. + * @constructor + * @param {Object} errorJson The Matrix error JSON returned from the homeserver. + * @prop {string} errcode The Matrix 'errcode' value, e.g. "M_FORBIDDEN". + * @prop {string} name Same as MatrixError.errcode but with a default unknown string. + * @prop {string} message The Matrix 'error' value, e.g. "Missing token." + * @prop {Object} data The raw Matrix error JSON used to construct this object. + * @prop {integer} httpStatus The numeric HTTP status code given + */ +module.exports.MatrixError = function MatrixError(errorJson) { + errorJson = errorJson || {}; + this.errcode = errorJson.errcode; + this.name = errorJson.errcode || "Unknown error code"; + this.message = errorJson.error || "Unknown message"; + this.data = errorJson; +}; +module.exports.MatrixError.prototype = Object.create(Error.prototype); +/** */ +module.exports.MatrixError.prototype.constructor = module.exports.MatrixError; diff --git a/matrix-js-sdk/src/indexeddb-helpers.js b/matrix-js-sdk/src/indexeddb-helpers.js new file mode 100644 index 000000000..68e36d9f4 --- /dev/null +++ b/matrix-js-sdk/src/indexeddb-helpers.js @@ -0,0 +1,52 @@ +/* +Copyright 2019 New Vector 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. +*/ + +import Promise from 'bluebird'; + +/** + * Check if an IndexedDB database exists. The only way to do so is to try opening it, so + * we do that and then delete it did not exist before. + * + * @param {Object} indexedDB The `indexedDB` interface + * @param {string} dbName The database name to test for + * @returns {boolean} Whether the database exists + */ +export function exists(indexedDB, dbName) { + return new Promise((resolve, reject) => { + let exists = true; + const req = indexedDB.open(dbName); + req.onupgradeneeded = () => { + // Since we did not provide an explicit version when opening, this event + // should only fire if the DB did not exist before at any version. + exists = false; + }; + req.onblocked = () => reject(); + req.onsuccess = () => { + const db = req.result; + db.close(); + if (!exists) { + // The DB did not exist before, but has been created as part of this + // existence check. Delete it now to restore previous state. Delete can + // actually take a while to complete in some browsers, so don't wait for + // it. This won't block future open calls that a store might issue next to + // properly set up the DB. + indexedDB.deleteDatabase(dbName); + } + resolve(exists); + }; + req.onerror = ev => reject(ev.target.error); + }); +} diff --git a/matrix-js-sdk/src/indexeddb-worker.js b/matrix-js-sdk/src/indexeddb-worker.js new file mode 100644 index 000000000..aa6312e2f --- /dev/null +++ b/matrix-js-sdk/src/indexeddb-worker.js @@ -0,0 +1,24 @@ +/* +Copyright 2017 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. +*/ + +/** + * Separate exports file for the indexeddb web worker, which is designed + * to be used separately + */ + +/** The {@link module:indexeddb-store-worker~IndexedDBStoreWorker} class. */ +module.exports.IndexedDBStoreWorker = require("./store/indexeddb-store-worker.js"); + diff --git a/matrix-js-sdk/src/interactive-auth.js b/matrix-js-sdk/src/interactive-auth.js new file mode 100644 index 000000000..57f164bd1 --- /dev/null +++ b/matrix-js-sdk/src/interactive-auth.js @@ -0,0 +1,513 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations 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. +*/ +"use strict"; + +/** @module interactive-auth */ +import Promise from 'bluebird'; +const url = require("url"); + +const utils = require("./utils"); +import logger from '../src/logger'; + +const EMAIL_STAGE_TYPE = "m.login.email.identity"; +const MSISDN_STAGE_TYPE = "m.login.msisdn"; + +/** + * Abstracts the logic used to drive the interactive auth process. + * + *

    Components implementing an interactive auth flow should instantiate one of + * these, passing in the necessary callbacks to the constructor. They should + * then call attemptAuth, which will return a promise which will resolve or + * reject when the interactive-auth process completes. + * + *

    Meanwhile, calls will be made to the startAuthStage and doRequest + * callbacks, and information gathered from the user can be submitted with + * submitAuthDict. + * + * @constructor + * @alias module:interactive-auth + * + * @param {object} opts options object + * + * @param {object} opts.matrixClient A matrix client to use for the auth process + * + * @param {object?} opts.authData error response from the last request. If + * null, a request will be made with no auth before starting. + * + * @param {function(object?): module:client.Promise} opts.doRequest + * called with the new auth dict to submit the request. Also passes a + * second deprecated arg which is a flag set to true if this request + * is a background request. The busyChanged callback should be used + * instead of the backfround flag. Should return a promise which resolves + * to the successful response or rejects with a MatrixError. + * + * @param {function(bool): module:client.Promise} opts.busyChanged + * called whenever the interactive auth logic becomes busy submitting + * information provided by the user or finsihes. After this has been + * called with true the UI should indicate that a request is in progress + * until it is called again with false. + * + * @param {function(string, object?)} opts.stateUpdated + * called when the status of the UI auth changes, ie. when the state of + * an auth stage changes of when the auth flow moves to a new stage. + * The arguments are: the login type (eg m.login.password); and an object + * which is either an error or an informational object specific to the + * login type. If the 'errcode' key is defined, the object is an error, + * and has keys: + * errcode: string, the textual error code, eg. M_UNKNOWN + * error: string, human readable string describing the error + * + * The login type specific objects are as follows: + * m.login.email.identity: + * * emailSid: string, the sid of the active email auth session + * + * @param {object?} opts.inputs Inputs provided by the user and used by different + * stages of the auto process. The inputs provided will affect what flow is chosen. + * + * @param {string?} opts.inputs.emailAddress An email address. If supplied, a flow + * using email verification will be chosen. + * + * @param {string?} opts.inputs.phoneCountry An ISO two letter country code. Gives + * the country that opts.phoneNumber should be resolved relative to. + * + * @param {string?} opts.inputs.phoneNumber A phone number. If supplied, a flow + * using phone number validation will be chosen. + * + * @param {string?} opts.sessionId If resuming an existing interactive auth session, + * the sessionId of that session. + * + * @param {string?} opts.clientSecret If resuming an existing interactive auth session, + * the client secret for that session + * + * @param {string?} opts.emailSid If returning from having completed m.login.email.identity + * auth, the sid for the email verification session. + * + * @param {function?} opts.requestEmailToken A function that takes the email address (string), + * clientSecret (string), attempt number (int) and sessionId (string) and calls the + * relevant requestToken function and returns the promise returned by that function. + * If the resulting promise rejects, the rejection will propagate through to the + * attemptAuth promise. + * + */ +function InteractiveAuth(opts) { + this._matrixClient = opts.matrixClient; + this._data = opts.authData || {}; + this._requestCallback = opts.doRequest; + this._busyChangedCallback = opts.busyChanged; + // startAuthStage included for backwards compat + this._stateUpdatedCallback = opts.stateUpdated || opts.startAuthStage; + this._resolveFunc = null; + this._rejectFunc = null; + this._inputs = opts.inputs || {}; + this._requestEmailTokenCallback = opts.requestEmailToken; + + if (opts.sessionId) this._data.session = opts.sessionId; + this._clientSecret = opts.clientSecret || this._matrixClient.generateClientSecret(); + this._emailSid = opts.emailSid; + if (this._emailSid === undefined) this._emailSid = null; + this._requestingEmailToken = false; + + this._chosenFlow = null; + this._currentStage = null; + + // if we are currently trying to submit an auth dict (which includes polling) + // the promise the will resolve/reject when it completes + this._submitPromise = null; +} + +InteractiveAuth.prototype = { + /** + * begin the authentication process. + * + * @return {module:client.Promise} which resolves to the response on success, + * or rejects with the error on failure. Rejects with NoAuthFlowFoundError if + * no suitable authentication flow can be found + */ + attemptAuth: function() { + // This promise will be quite long-lived and will resolve when the + // request is authenticated and completes successfully. + return new Promise((resolve, reject) => { + this._resolveFunc = resolve; + this._rejectFunc = reject; + + // if we have no flows, try a request (we'll have + // just a session ID in _data if resuming) + if (!this._data.flows) { + if (this._busyChangedCallback) this._busyChangedCallback(true); + this._doRequest(this._data).finally(() => { + if (this._busyChangedCallback) this._busyChangedCallback(false); + }); + } else { + this._startNextAuthStage(); + } + }); + }, + + /** + * Poll to check if the auth session or current stage has been + * completed out-of-band. If so, the attemptAuth promise will + * be resolved. + */ + poll: async function() { + if (!this._data.session) return; + // if we currently have a request in flight, there's no point making + // another just to check what the status is + if (this._submitPromise) return; + + let authDict = {}; + if (this._currentStage == EMAIL_STAGE_TYPE) { + // The email can be validated out-of-band, but we need to provide the + // creds so the HS can go & check it. + if (this._emailSid) { + const idServerParsedUrl = url.parse( + this._matrixClient.getIdentityServerUrl(), + ); + authDict = { + type: EMAIL_STAGE_TYPE, + threepid_creds: { + sid: this._emailSid, + client_secret: this._clientSecret, + id_server: idServerParsedUrl.host, + }, + }; + } + } + + this.submitAuthDict(authDict, true); + }, + + /** + * get the auth session ID + * + * @return {string} session id + */ + getSessionId: function() { + return this._data ? this._data.session : undefined; + }, + + /** + * get the client secret used for validation sessions + * with the ID server. + * + * @return {string} client secret + */ + getClientSecret: function() { + return this._clientSecret; + }, + + /** + * get the server params for a given stage + * + * @param {string} loginType login type for the stage + * @return {object?} any parameters from the server for this stage + */ + getStageParams: function(loginType) { + let params = {}; + if (this._data && this._data.params) { + params = this._data.params; + } + return params[loginType]; + }, + + getChosenFlow() { + return this._chosenFlow; + }, + + /** + * submit a new auth dict and fire off the request. This will either + * make attemptAuth resolve/reject, or cause the startAuthStage callback + * to be called for a new stage. + * + * @param {object} authData new auth dict to send to the server. Should + * include a `type` propterty denoting the login type, as well as any + * other params for that stage. + * @param {bool} background If true, this request failing will not result + * in the attemptAuth promise being rejected. This can be set to true + * for requests that just poll to see if auth has been completed elsewhere. + */ + submitAuthDict: async function(authData, background) { + if (!this._resolveFunc) { + throw new Error("submitAuthDict() called before attemptAuth()"); + } + + if (!background && this._busyChangedCallback) { + this._busyChangedCallback(true); + } + + // if we're currently trying a request, wait for it to finish + // as otherwise we can get multiple 200 responses which can mean + // things like multiple logins for register requests. + // (but discard any expections as we only care when its done, + // not whether it worked or not) + while (this._submitPromise) { + try { + await this._submitPromise; + } catch (e) { + } + } + + // use the sessionid from the last request. + const auth = { + session: this._data.session, + }; + utils.extend(auth, authData); + + try { + // NB. the 'background' flag is deprecated by the busyChanged + // callback and is here for backwards compat + this._submitPromise = this._doRequest(auth, background); + await this._submitPromise; + } finally { + this._submitPromise = null; + if (!background && this._busyChangedCallback) { + this._busyChangedCallback(false); + } + } + }, + + /** + * Gets the sid for the email validation session + * Specific to m.login.email.identity + * + * @returns {string} The sid of the email auth session + */ + getEmailSid: function() { + return this._emailSid; + }, + + /** + * Sets the sid for the email validation session + * This must be set in order to successfully poll for completion + * of the email validation. + * Specific to m.login.email.identity + * + * @param {string} sid The sid for the email validation session + */ + setEmailSid: function(sid) { + this._emailSid = sid; + }, + + /** + * Fire off a request, and either resolve the promise, or call + * startAuthStage. + * + * @private + * @param {object?} auth new auth dict, including session id + * @param {bool?} background If true, this request is a background poll, so it + * failing will not result in the attemptAuth promise being rejected. + * This can be set to true for requests that just poll to see if auth has + * been completed elsewhere. + */ + _doRequest: async function(auth, background) { + try { + const result = await this._requestCallback(auth, background); + this._resolveFunc(result); + } catch (error) { + // sometimes UI auth errors don't come with flows + const errorFlows = error.data ? error.data.flows : null; + const haveFlows = Boolean(this._data.flows) || Boolean(errorFlows); + if (error.httpStatus !== 401 || !error.data || !haveFlows) { + // doesn't look like an interactive-auth failure. + if (!background) { + this._rejectFunc(error); + } else { + // We ignore all failures here (even non-UI auth related ones) + // since we don't want to suddenly fail if the internet connection + // had a blip whilst we were polling + logger.log( + "Background poll request failed doing UI auth: ignoring", + error, + ); + } + } + // if the error didn't come with flows, completed flows or session ID, + // copy over the ones we have. Synapse sometimes sends responses without + // any UI auth data (eg. when polling for email validation, if the email + // has not yet been validated). This appears to be a Synapse bug, which + // we workaround here. + if (!error.data.flows && !error.data.completed && !error.data.session) { + error.data.flows = this._data.flows; + error.data.completed = this._data.completed; + error.data.session = this._data.session; + } + this._data = error.data; + this._startNextAuthStage(); + + if ( + !this._emailSid && + !this._requestingEmailToken && + this._chosenFlow.stages.includes('m.login.email.identity') + ) { + // If we've picked a flow with email auth, we send the email + // now because we want the request to fail as soon as possible + // if the email address is not valid (ie. already taken or not + // registered, depending on what the operation is). + this._requestingEmailToken = true; + try { + const requestTokenResult = await this._requestEmailTokenCallback( + this._inputs.emailAddress, + this._clientSecret, + 1, // TODO: Multiple send attempts? + this._data.session, + ); + this._emailSid = requestTokenResult.sid; + // NB. promise is not resolved here - at some point, doRequest + // will be called again and if the user has jumped through all + // the hoops correctly, auth will be complete and the request + // will succeed. + // Also, we should expose the fact that this request has compledted + // so clients can know that the email has actually been sent. + } catch (e) { + // we failed to request an email token, so fail the request. + // This could be due to the email already beeing registered + // (or not being registered, depending on what we're trying + // to do) or it could be a network failure. Either way, pass + // the failure up as the user can't complete auth if we can't + // send the email, foe whatever reason. + this._rejectFunc(e); + } finally { + this._requestingEmailToken = false; + } + } + } + }, + + /** + * Pick the next stage and call the callback + * + * @private + * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found + */ + _startNextAuthStage: function() { + const nextStage = this._chooseStage(); + if (!nextStage) { + throw new Error("No incomplete flows from the server"); + } + this._currentStage = nextStage; + + if (nextStage === 'm.login.dummy') { + this.submitAuthDict({ + type: 'm.login.dummy', + }); + return; + } + + if (this._data.errcode || this._data.error) { + this._stateUpdatedCallback(nextStage, { + errcode: this._data.errcode || "", + error: this._data.error || "", + }); + return; + } + + const stageStatus = {}; + if (nextStage == EMAIL_STAGE_TYPE) { + stageStatus.emailSid = this._emailSid; + } + this._stateUpdatedCallback(nextStage, stageStatus); + }, + + /** + * Pick the next auth stage + * + * @private + * @return {string?} login type + * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found + */ + _chooseStage: function() { + if (this._chosenFlow === null) { + this._chosenFlow = this._chooseFlow(); + } + logger.log("Active flow => %s", JSON.stringify(this._chosenFlow)); + const nextStage = this._firstUncompletedStage(this._chosenFlow); + logger.log("Next stage: %s", nextStage); + return nextStage; + }, + + /** + * Pick one of the flows from the returned list + * If a flow using all of the inputs is found, it will + * be returned, otherwise, null will be returned. + * + * Only flows using all given inputs are chosen because it + * is likley to be surprising if the user provides a + * credential and it is not used. For example, for registration, + * this could result in the email not being used which would leave + * the account with no means to reset a password. + * + * @private + * @return {object} flow + * @throws {NoAuthFlowFoundError} If no suitable authentication flow can be found + */ + _chooseFlow: function() { + const flows = this._data.flows || []; + + // we've been given an email or we've already done an email part + const haveEmail = Boolean(this._inputs.emailAddress) || Boolean(this._emailSid); + const haveMsisdn = ( + Boolean(this._inputs.phoneCountry) && + Boolean(this._inputs.phoneNumber) + ); + + for (const flow of flows) { + let flowHasEmail = false; + let flowHasMsisdn = false; + for (const stage of flow.stages) { + if (stage === EMAIL_STAGE_TYPE) { + flowHasEmail = true; + } else if (stage == MSISDN_STAGE_TYPE) { + flowHasMsisdn = true; + } + } + + if (flowHasEmail == haveEmail && flowHasMsisdn == haveMsisdn) { + return flow; + } + } + // Throw an error with a fairly generic description, but with more + // information such that the app can give a better one if so desired. + const err = new Error("No appropriate authentication flow found"); + err.name = 'NoAuthFlowFoundError'; + err.required_stages = []; + if (haveEmail) err.required_stages.push(EMAIL_STAGE_TYPE); + if (haveMsisdn) err.required_stages.push(MSISDN_STAGE_TYPE); + err.available_flows = flows; + throw err; + }, + + /** + * Get the first uncompleted stage in the given flow + * + * @private + * @param {object} flow + * @return {string} login type + */ + _firstUncompletedStage: function(flow) { + const completed = (this._data || {}).completed || []; + for (let i = 0; i < flow.stages.length; ++i) { + const stageType = flow.stages[i]; + if (completed.indexOf(stageType) === -1) { + return stageType; + } + } + }, +}; + + +/** */ +module.exports = InteractiveAuth; diff --git a/matrix-js-sdk/src/logger.js b/matrix-js-sdk/src/logger.js new file mode 100644 index 000000000..ee77c2918 --- /dev/null +++ b/matrix-js-sdk/src/logger.js @@ -0,0 +1,36 @@ +/* +Copyright 2018 André Jaenisch + +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 logger + */ +const log = require("loglevel"); + +// This is to demonstrate, that you can use any namespace you want. +// Namespaces allow you to turn on/off the logging for specific parts of the +// application. +// An idea would be to control this via an environment variable (on Node.js). +// See https://www.npmjs.com/package/debug to see how this could be implemented +// Part of #332 is introducing a logging library in the first place. +const DEFAULT_NAME_SPACE = "matrix"; +const logger = log.getLogger(DEFAULT_NAME_SPACE); +logger.setLevel(log.levels.DEBUG); + +/** + * Drop-in replacement for console using {@link https://www.npmjs.com/package/loglevel|loglevel}. + * Can be tailored down to specific use cases if needed. +*/ +module.exports = logger; diff --git a/matrix-js-sdk/src/matrix.js b/matrix-js-sdk/src/matrix.js new file mode 100644 index 000000000..0460927ff --- /dev/null +++ b/matrix-js-sdk/src/matrix.js @@ -0,0 +1,241 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 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. +*/ +"use strict"; + +/** The {@link module:ContentHelpers} object */ +module.exports.ContentHelpers = require("./content-helpers"); +/** The {@link module:models/event.MatrixEvent|MatrixEvent} class. */ +module.exports.MatrixEvent = require("./models/event").MatrixEvent; +/** The {@link module:models/event.EventStatus|EventStatus} enum. */ +module.exports.EventStatus = require("./models/event").EventStatus; +/** The {@link module:store/memory.MemoryStore|MemoryStore} class. */ +module.exports.MemoryStore = require("./store/memory").MemoryStore; +/** + * The {@link module:store/memory.MemoryStore|MemoryStore} class was previously + * exported as `MatrixInMemoryStore`, so this is preserved for SDK consumers. + * @deprecated Prefer `MemoryStore` going forward. + */ +module.exports.MatrixInMemoryStore = module.exports.MemoryStore; +/** The {@link module:store/indexeddb.IndexedDBStore|IndexedDBStore} class. */ +module.exports.IndexedDBStore = require("./store/indexeddb").IndexedDBStore; +/** The {@link module:store/indexeddb.IndexedDBStoreBackend|IndexedDBStoreBackend} class. */ +module.exports.IndexedDBStoreBackend = require("./store/indexeddb").IndexedDBStoreBackend; +/** The {@link module:sync-accumulator.SyncAccumulator|SyncAccumulator} class. */ +module.exports.SyncAccumulator = require("./sync-accumulator"); +/** The {@link module:http-api.MatrixHttpApi|MatrixHttpApi} class. */ +module.exports.MatrixHttpApi = require("./http-api").MatrixHttpApi; +/** The {@link module:http-api.MatrixError|MatrixError} class. */ +module.exports.MatrixError = require("./http-api").MatrixError; +/** The {@link module:errors.InvalidStoreError|InvalidStoreError} class. */ +module.exports.InvalidStoreError = require("./errors").InvalidStoreError; +/** The {@link module:client.MatrixClient|MatrixClient} class. */ +module.exports.MatrixClient = require("./client").MatrixClient; +/** The {@link module:models/room|Room} class. */ +module.exports.Room = require("./models/room"); +/** The {@link module:models/group|Group} class. */ +module.exports.Group = require("./models/group"); +/** The {@link module:models/event-timeline~EventTimeline} class. */ +module.exports.EventTimeline = require("./models/event-timeline"); +/** The {@link module:models/event-timeline-set~EventTimelineSet} class. */ +module.exports.EventTimelineSet = require("./models/event-timeline-set"); +/** The {@link module:models/room-member|RoomMember} class. */ +module.exports.RoomMember = require("./models/room-member"); +/** The {@link module:models/room-state~RoomState|RoomState} class. */ +module.exports.RoomState = require("./models/room-state"); +/** The {@link module:models/user~User|User} class. */ +module.exports.User = require("./models/user"); +/** The {@link module:scheduler~MatrixScheduler|MatrixScheduler} class. */ +module.exports.MatrixScheduler = require("./scheduler"); +/** The {@link module:store/session/webstorage~WebStorageSessionStore| + * WebStorageSessionStore} class. Work in progress; unstable. */ +module.exports.WebStorageSessionStore = require("./store/session/webstorage"); +/** True if crypto libraries are being used on this client. */ +module.exports.CRYPTO_ENABLED = require("./client").CRYPTO_ENABLED; +/** {@link module:content-repo|ContentRepo} utility functions. */ +module.exports.ContentRepo = require("./content-repo"); +/** The {@link module:filter~Filter|Filter} class. */ +module.exports.Filter = require("./filter"); +/** The {@link module:timeline-window~TimelineWindow} class. */ +module.exports.TimelineWindow = require("./timeline-window").TimelineWindow; +/** The {@link module:interactive-auth} class. */ +module.exports.InteractiveAuth = require("./interactive-auth"); +/** The {@link module:auto-discovery|AutoDiscovery} class. */ +module.exports.AutoDiscovery = require("./autodiscovery").AutoDiscovery; + + +module.exports.MemoryCryptoStore = + require("./crypto/store/memory-crypto-store").default; +module.exports.IndexedDBCryptoStore = + require("./crypto/store/indexeddb-crypto-store").default; + +/** + * Create a new Matrix Call. + * @function + * @param {module:client.MatrixClient} client The MatrixClient instance to use. + * @param {string} roomId The room the call is in. + * @return {module:webrtc/call~MatrixCall} The Matrix call or null if the browser + * does not support WebRTC. + */ +module.exports.createNewMatrixCall = require("./webrtc/call").createNewMatrixCall; + + +/** + * Set a preferred audio output device to use for MatrixCalls + * @function + * @param {string=} deviceId the identifier for the device + * undefined treated as unset + */ +module.exports.setMatrixCallAudioOutput = require('./webrtc/call').setAudioOutput; +/** + * Set a preferred audio input device to use for MatrixCalls + * @function + * @param {string=} deviceId the identifier for the device + * undefined treated as unset + */ +module.exports.setMatrixCallAudioInput = require('./webrtc/call').setAudioInput; +/** + * Set a preferred video input device to use for MatrixCalls + * @function + * @param {string=} deviceId the identifier for the device + * undefined treated as unset + */ +module.exports.setMatrixCallVideoInput = require('./webrtc/call').setVideoInput; + + +// expose the underlying request object so different environments can use +// different request libs (e.g. request or browser-request) +let request; +/** + * The function used to perform HTTP requests. Only use this if you want to + * use a different HTTP library, e.g. Angular's $http. This should + * be set prior to calling {@link createClient}. + * @param {requestFunction} r The request function to use. + */ +module.exports.request = function(r) { + request = r; +}; + +/** + * Return the currently-set request function. + * @return {requestFunction} The current request function. + */ +module.exports.getRequest = function() { + return request; +}; + +/** + * Apply wrapping code around the request function. The wrapper function is + * installed as the new request handler, and when invoked it is passed the + * previous value, along with the options and callback arguments. + * @param {requestWrapperFunction} wrapper The wrapping function. + */ +module.exports.wrapRequest = function(wrapper) { + const origRequest = request; + request = function(options, callback) { + return wrapper(origRequest, options, callback); + }; +}; + + +let cryptoStoreFactory = () => new module.exports.MemoryCryptoStore; + +/** + * Configure a different factory to be used for creating crypto stores + * + * @param {Function} fac a function which will return a new + * {@link module:crypto.store.base~CryptoStore}. + */ +module.exports.setCryptoStoreFactory = function(fac) { + cryptoStoreFactory = fac; +}; + +/** + * Construct a Matrix Client. Similar to {@link module:client~MatrixClient} + * except that the 'request', 'store' and 'scheduler' dependencies are satisfied. + * @param {(Object|string)} opts The configuration options for this client. If + * this is a string, it is assumed to be the base URL. These configuration + * options will be passed directly to {@link module:client~MatrixClient}. + * @param {Object} opts.store If not set, defaults to + * {@link module:store/memory.MemoryStore}. + * @param {Object} opts.scheduler If not set, defaults to + * {@link module:scheduler~MatrixScheduler}. + * @param {requestFunction} opts.request If not set, defaults to the function + * supplied to {@link request} which defaults to the request module from NPM. + * + * @param {module:crypto.store.base~CryptoStore=} opts.cryptoStore + * crypto store implementation. Calls the factory supplied to + * {@link setCryptoStoreFactory} if unspecified; or if no factory has been + * specified, uses a default implementation (indexeddb in the browser, + * in-memory otherwise). + * + * @return {MatrixClient} A new matrix client. + * @see {@link module:client~MatrixClient} for the full list of options for + * opts. + */ +module.exports.createClient = function(opts) { + if (typeof opts === "string") { + opts = { + "baseUrl": opts, + }; + } + opts.request = opts.request || request; + opts.store = opts.store || new module.exports.MemoryStore({ + localStorage: global.localStorage, + }); + opts.scheduler = opts.scheduler || new module.exports.MatrixScheduler(); + opts.cryptoStore = opts.cryptoStore || cryptoStoreFactory(); + return new module.exports.MatrixClient(opts); +}; + +/** + * The request function interface for performing HTTP requests. This matches the + * API for the {@link https://github.com/request/request#requestoptions-callback| + * request NPM module}. The SDK will attempt to call this function in order to + * perform an HTTP request. + * @callback requestFunction + * @param {Object} opts The options for this HTTP request. + * @param {string} opts.uri The complete URI. + * @param {string} opts.method The HTTP method. + * @param {Object} opts.qs The query parameters to append to the URI. + * @param {Object} opts.body The JSON-serializable object. + * @param {boolean} opts.json True if this is a JSON request. + * @param {Object} opts._matrix_opts The underlying options set for + * {@link MatrixHttpApi}. + * @param {requestCallback} callback The request callback. + */ + +/** + * A wrapper for the request function interface. + * @callback requestWrapperFunction + * @param {requestFunction} origRequest The underlying request function being + * wrapped + * @param {Object} opts The options for this HTTP request, given in the same + * form as {@link requestFunction}. + * @param {requestCallback} callback The request callback. + */ + + /** + * The request callback interface for performing HTTP requests. This matches the + * API for the {@link https://github.com/request/request#requestoptions-callback| + * request NPM module}. The SDK will implement a callback which meets this + * interface in order to handle the HTTP response. + * @callback requestCallback + * @param {Error} err The error if one occurred, else falsey. + * @param {Object} response The HTTP response which consists of + * {statusCode: {Number}, headers: {Object}} + * @param {Object} body The parsed HTTP response body. + */ diff --git a/matrix-js-sdk/src/models/event-context.js b/matrix-js-sdk/src/models/event-context.js new file mode 100644 index 000000000..c2f298b99 --- /dev/null +++ b/matrix-js-sdk/src/models/event-context.js @@ -0,0 +1,119 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; + +/** + * @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 + */ +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); + } +}; + +/** + * The EventContext class + */ +module.exports = EventContext; diff --git a/matrix-js-sdk/src/models/event-timeline-set.js b/matrix-js-sdk/src/models/event-timeline-set.js new file mode 100644 index 000000000..ea0a6f72b --- /dev/null +++ b/matrix-js-sdk/src/models/event-timeline-set.js @@ -0,0 +1,847 @@ +/* +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. +*/ +"use strict"; +/** + * @module models/event-timeline-set + */ +const EventEmitter = require("events").EventEmitter; +const utils = require("../utils"); +const EventTimeline = require("./event-timeline"); +import {EventStatus} from "./event"; +import logger from '../../src/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. + */ +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 = new Map(); + + 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 = new Map(); + } +} +utils.inherits(EventTimelineSet, EventEmitter); + +/** + * 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.get(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.get(oldEventId); + if (existingTimeline) { + this._eventIdToTimeline.delete(oldEventId); + this._eventIdToTimeline.set(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 = new Map(); + } 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.get(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 utils.findElement(tl.getEvents(), 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.get(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' + */ +EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) { + if (this._filter) { + const events = this._filter.filterRoomTimeline([event]); + if (!events.length) { + return; + } + } + + const timeline = this._eventIdToTimeline.get(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); +}; + +/** + * 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 + * + * @fires module:client~MatrixClient#event:"Room.timeline" + */ +EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, + toStartOfTimeline) { + const eventId = event.getId(); + timeline.addEvent(event, toStartOfTimeline); + this._eventIdToTimeline.set(eventId, timeline); + + this.setRelationsTarget(event); + this.aggregateRelations(event); + + const data = { + timeline: timeline, + liveEvent: !toStartOfTimeline && timeline == this._liveTimeline, + }; + 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.get(oldEventId); + if (existingTimeline) { + this._eventIdToTimeline.delete(oldEventId); + this._eventIdToTimeline.set(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.get(eventId); + if (!timeline) { + return null; + } + + const removed = timeline.removeEvent(eventId); + if (removed) { + this._eventIdToTimeline.delete(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.get(eventId1); + const timeline2 = this._eventIdToTimeline.get(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, 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. + * + * @returns {Relations} + * A container for relation events. + */ +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.get(eventId) || new Map(); + const relationsWithRelType = relationsForEvent.get(relationType) || new Map(); + return relationsWithRelType.get(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.get(event.getId()); + if (!relationsForEvent) { + return; + } + // don't need it for non m.replace relations for now + const relationsWithRelType = relationsForEvent["m.replace"]; + if (!relationsWithRelType) { + return; + } + // only doing replacements for messages for now (e.g. edits) + const relationsWithEventType = relationsWithRelType["m.room.message"]; + + if (relationsWithEventType) { + 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.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.get(relatesToEventId); + if (!relationsForEvent) { + relationsForEvent = new Map(); + this._relations.set(relatesToEventId, relationsForEvent); + } + let relationsWithRelType = relationsForEvent.get(relationType); + if (!relationsWithRelType) { + relationsWithRelType = new Map(); + relationsForEvent.set(relationType, relationsWithRelType); + } + let relationsWithEventType = relationsWithRelType.get(eventType); + + if (!relationsWithEventType) { + relationsWithEventType = new Relations( + relationType, + eventType, + this.room, + ); + relationsWithRelType.set(eventType, relationsWithEventType); + const relatesToEvent = this.findEventById(relatesToEventId); + if (relatesToEvent) { + relationsWithEventType.setTargetEvent(relatesToEvent); + relatesToEvent.emit("Event.relationsCreated", relationType, eventType); + } + } + + relationsWithEventType.addEvent(event); +}; + +/** + * The EventTimelineSet class. + */ +module.exports = EventTimelineSet; + +/** + * 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: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/matrix-js-sdk/src/models/event-timeline.js b/matrix-js-sdk/src/models/event-timeline.js new file mode 100644 index 000000000..63846cebd --- /dev/null +++ b/matrix-js-sdk/src/models/event-timeline.js @@ -0,0 +1,403 @@ +/* +Copyright 2016, 2017 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. +*/ +"use strict"; + +/** + * @module models/event-timeline + */ + +const RoomState = require("./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 + */ +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; +}; + + +/** + * The EventTimeline class + */ +module.exports = EventTimeline; diff --git a/matrix-js-sdk/src/models/event.js b/matrix-js-sdk/src/models/event.js new file mode 100644 index 000000000..d4fd7f49d --- /dev/null +++ b/matrix-js-sdk/src/models/event.js @@ -0,0 +1,1095 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; + +/** + * This is an internal module. See {@link MatrixEvent} and {@link RoomEvent} for + * the public classes. + * @module models/event + */ + +import Promise from 'bluebird'; +import {EventEmitter} from 'events'; +import utils from '../utils.js'; +import logger from '../../src/logger'; + +/** + * Enum for event statuses. + * @readonly + * @enum {string} + */ +const EventStatus = { + /** The event was not sent and will no longer be retried. */ + NOT_SENT: "not_sent", + + /** The message is being encrypted */ + ENCRYPTING: "encrypting", + + /** The event is in the process of being sent. */ + 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", + + /** The event was cancelled before it was successfully sent. */ + CANCELLED: "cancelled", +}; +module.exports.EventStatus = EventStatus; + +const interns = new Map(); +function intern(str) { + if (str instanceof String) { + str = str.toString(); + } + if (!interns.has(str)) { + interns.set(str, str); + } + return interns.get(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. + */ +module.exports.MatrixEvent = function MatrixEvent( + 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 Riot 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]); + }); + + ["membership", "avatar_url", "displayname"].forEach((prop) => { + if (!event.content || !event.content[prop]) { + return; + } + event.content[prop] = intern(event.content[prop]); + }); + + ["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]); + }); + + this.event = event || {}; + + 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; + + this._clearEvent = {}; + + /* curve25519 key which we believe belongs to the sender of the event. See + * getSenderKey() + */ + this._senderCurve25519Key = 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; + + /* curve25519 keys of devices involved in telling us about the + * _senderCurve25519Key and _claimedEd25519Key. + * See getForwardingCurve25519KeyChain(). + */ + this._forwardingCurve25519KeyChain = []; + + /* if we have a process decrypting this event, a Promise which resolves + * when it is finished. Normally null. + */ + this._decryptionPromise = 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; +}; +utils.inherits(module.exports.MatrixEvent, EventEmitter); + + +utils.extend(module.exports.MatrixEvent.prototype, { + + /** + * Get the event_id for this event. + * @return {string} The event ID, e.g. $143350589368169JsLZx:localhost + * + */ + getId: function() { + return this.event.event_id; + }, + + /** + * Get the user_id for this event. + * @return {string} The user ID, e.g. @alice:matrix.org + */ + getSender: function() { + 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; + }, + + /** + * Get the (possibly encrypted) type of the event that will be sent to the + * homeserver. + * + * @return {string} The event type. + */ + getWireType: function() { + return this.event.type; + }, + + /** + * Get the room_id for this event. This will return undefined + * for m.presence events. + * @return {string} The room ID, e.g. !cURbafjkfsMDVwdRDQ:matrix.org + * + */ + getRoomId: function() { + return this.event.room_id; + }, + + /** + * Get the timestamp of this event. + * @return {Number} The event timestamp, e.g. 1433502692297 + */ + getTs: function() { + 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() { + 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 + * was replaced by another event. + * + * @return {Object} The event content JSON, or an empty object. + */ + getOriginalContent: function() { + if (this._localRedactionEvent) { + return {}; + } + return this._clearEvent.content || this.event.content || {}; + }, + + /** + * Get the (decrypted, if necessary) event content JSON, + * or the content from the replacing event, if any. + * See `makeReplaced`. + * + * @return {Object} The event content JSON, or an empty object. + */ + getContent: function() { + if (this._localRedactionEvent) { + return {}; + } else if (this._replacingEvent) { + return this._replacingEvent.getContent()["m.new_content"] || {}; + } else { + return this.getOriginalContent(); + } + }, + + /** + * Get the (possibly encrypted) event content JSON that will be sent to the + * homeserver. + * + * @return {Object} The event content JSON, or an empty object. + */ + getWireContent: function() { + 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() { + // 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 + * 'forward-looking' or not. This can be modified via event.forwardLooking. + * In practice, this means we get the chronologically earlier content value + * for this event (this method should surely be called getEarlierContent) + * This method is experimental and may change. + * @return {Object} event.content if this event is forward-looking, else + * event.prev_content. + */ + getDirectionalContent: function() { + return this.forwardLooking ? this.getContent() : this.getPrevContent(); + }, + + /** + * Get the age of this event. This represents the age of the event when the + * event arrived at the device, and not the age of the event when this + * function was called. + * @return {Number} The age of this event in milliseconds. + */ + getAge: function() { + return this.getUnsigned().age || this.event.age; // v2 / v1 + }, + + /** + * 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() { + return this.event.state_key; + }, + + /** + * Check if this event is a state event. + * @return {boolean} True if this is a state event. + */ + isState: function() { + return this.event.state_key !== undefined; + }, + + /** + * Replace the content of this event with encrypted versions. + * (This is used when sending an event; it should not be used by applications). + * + * @internal + * + * @param {string} crypto_type type of the encrypted event - typically + * "m.room.encrypted" + * + * @param {object} crypto_content raw 'content' for the encrypted event. + * + * @param {string} senderCurve25519Key curve25519 key to record for the + * sender of this event. + * See {@link module:models/event.MatrixEvent#getSenderKey}. + * + * @param {string} claimedEd25519Key claimed ed25519 key to record for the + * sender if this event. + * See {@link module:models/event.MatrixEvent#getClaimedEd25519Key} + */ + makeEncrypted: function( + crypto_type, crypto_content, senderCurve25519Key, claimedEd25519Key, + ) { + // keep the plain-text data for 'view source' + 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; + }, + + /** + * Check if this event is currently being decrypted. + * + * @return {boolean} True if this event is currently being decrypted, else false. + */ + isBeingDecrypted: function() { + return this._decryptionPromise != null; + }, + + /** + * Check if this event is an encrypted event which we failed to decrypt + * + * (This implies that we might retry decryption at some point in the future) + * + * @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"; + }, + + /** + * Start the process of trying to decrypt this event. + * + * (This is used within the SDK: it isn't intended for use by applications) + * + * @internal + * + * @param {module:crypto} crypto crypto module + * + * @returns {Promise} promise which resolves (to undefined) when the decryption + * attempt is completed. + */ + attemptDecryption: async function(crypto) { + // start with a couple of sanity checks. + if (!this.isEncrypted()) { + throw new Error("Attempt to decrypt event which isn't encrypted"); + } + + if ( + 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( + "Attempt to decrypt event which has already been encrypted", + ); + } + + // if we already have a decryption attempt in progress, then it may + // fail because it was using outdated info. We now have reason to + // succeed where it failed before, but we don't want to have multiple + // attempts going at the same time, so just set a flag that says we have + // new info. + // + if (this._decryptionPromise) { + logger.log( + `Event ${this.getId()} already being decrypted; queueing a retry`, + ); + this._retryDecryption = true; + return this._decryptionPromise; + } + + this._decryptionPromise = this._decryptionLoop(crypto); + return this._decryptionPromise; + }, + + /** + * Cancel any room key request for this event and resend another. + * + * @param {module:crypto} crypto crypto module + * @param {string} userId the user who received this event + * + * @returns {Promise} a promise that resolves when the request is queued + */ + cancelAndResendKeyRequest: function(crypto, userId) { + const wireContent = this.getWireContent(); + return crypto.requestRoomKey({ + algorithm: wireContent.algorithm, + room_id: this.getRoomId(), + session_id: wireContent.session_id, + sender_key: wireContent.sender_key, + }, this.getKeyRequestRecipients(userId), true); + }, + + /** + * Calculate the recipients for keyshare requests. + * + * @param {string} userId the user who received this event. + * + * @returns {Array} array of recipients + */ + getKeyRequestRecipients: function(userId) { + // send the request to all of our own devices, and the + // original sending device if it wasn't us. + const wireContent = this.getWireContent(); + const recipients = [{ + userId, deviceId: '*', + }]; + const sender = this.getSender(); + if (sender !== userId) { + recipients.push({ + userId: sender, deviceId: wireContent.device_id, + }); + } + return recipients; + }, + + _decryptionLoop: async function(crypto) { + // 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(); + + while (true) { + this._retryDecryption = false; + + let res; + let err; + try { + if (!crypto) { + res = this._badEncryptedMessage("Encryption not enabled"); + } else { + res = await crypto.decryptEvent(this); + } + } catch (e) { + if (e.name !== "DecryptionError") { + // not a decryption error: log the whole exception as an error + // (and don't bother with a retry) + logger.error( + `Error decrypting event (id=${this.getId()}): ${e.stack || e}`, + ); + this._decryptionPromise = null; + this._retryDecryption = false; + return; + } + + err = e; + + // see if we have a retry queued. + // + // NB: make sure to keep this check in the same tick of the + // event loop as `_decryptionPromise = null` below - otherwise we + // risk a race: + // + // * 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 + // * A: we continue below, clear _decryptionPromise, and + // never do the retry. + // + if (this._retryDecryption) { + // decryption error, but we have a retry queued. + logger.log( + `Got error decrypting event (id=${this.getId()}: ` + + `${e}), but retrying`, + ); + continue; + } + + // decryption error, no retries queued. Warn about the error and + // set it to m.bad.encrypted. + logger.warn( + `Error decrypting event (id=${this.getId()}): ${e.detailedString}`, + ); + + res = this._badEncryptedMessage(e.message); + } + + // at this point, we've either successfully decrypted the event, or have given up + // (and set res to a 'badEncryptedMessage'). Either way, we can now set the + // cleartext of the event and raise Event.decrypted. + // + // make sure we clear '_decryptionPromise' before sending the 'Event.decrypted' event, + // 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. + // + this._decryptionPromise = null; + 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 + // so that existing rules can be re-run over the applicable properties. Stuff like + // highlighting when the user's name is mentioned rely on this happening. We also want + // to set the push actions before emitting so that any notification listeners don't + // pick up the wrong contents. + this.setPushActions(null); + + this.emit("Event.decrypted", this, err); + + return; + } + }, + + _badEncryptedMessage: function(reason) { + return { + clearEvent: { + type: "m.room.message", + content: { + msgtype: "m.bad.encrypted", + body: "** Unable to decrypt: " + reason + " **", + }, + }, + }; + }, + + /** + * Update the cleartext data on this event. + * + * (This is used after decrypting an event; it should not be used by applications). + * + * @internal + * + * @fires module:models/event.MatrixEvent#"Event.decrypted" + * + * @param {module:crypto~EventDecryptionResult} decryptionResult + * the decryption result, including the plaintext and some key info + */ + _setClearData: function(decryptionResult) { + this._clearEvent = decryptionResult.clearEvent; + this._senderCurve25519Key = + decryptionResult.senderCurve25519Key || null; + this._claimedEd25519Key = + decryptionResult.claimedEd25519Key || null; + this._forwardingCurve25519KeyChain = + decryptionResult.forwardingCurve25519KeyChain || []; + }, + + /** + * Gets the cleartext content for this event. If the event is not encrypted, + * or encryption has not been completed, this will return null. + * + * @returns {Object} The cleartext (decrypted) content for the event + */ + getClearContent: function() { + 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() { + return this.event.type === "m.room.encrypted"; + }, + + /** + * The curve25519 key for the device that we think sent this event + * + * For an Olm-encrypted event, this is inferred directly from the DH + * exchange at the start of the session: the curve25519 key is involved in + * the DH exchange, so only a device which holds the private part of that + * key can establish such a session. + * + * For a megolm-encrypted event, it is inferred from the Olm message which + * established the megolm session + * + * @return {string} + */ + getSenderKey: function() { + return this._senderCurve25519Key; + }, + + /** + * The additional keys the sender of this encrypted event claims to possess. + * + * Just a wrapper for #getClaimedEd25519Key (q.v.) + * + * @return {Object} + */ + getKeysClaimed: function() { + return { + ed25519: this._claimedEd25519Key, + }; + }, + + /** + * Get the ed25519 the sender of this event claims to own. + * + * For Olm messages, this claim is encoded directly in the plaintext of the + * event itself. For megolm messages, it is implied by the m.room_key event + * which established the megolm session. + * + * Until we download the device list of the sender, it's just a claim: the + * device list gives a proof that the owner of the curve25519 key used for + * this event (and returned by #getSenderKey) also owns the ed25519 key by + * signing the public curve25519 key with the ed25519 key. + * + * In general, applications should not use this method directly, but should + * instead use MatrixClient.getEventSenderDeviceInfo. + * + * @return {string} + */ + getClaimedEd25519Key: function() { + return this._claimedEd25519Key; + }, + + /** + * Get the curve25519 keys of the devices which were involved in telling us + * about the claimedEd25519Key and sender curve25519 key. + * + * Normally this will be empty, but in the case of a forwarded megolm + * session, the sender keys are sent to us by another device (the forwarding + * device), which we need to trust to do this. In that case, the result will + * be a list consisting of one entry. + * + * If the device that sent us the key (A) got it from another device which + * it wasn't prepared to vouch for (B), the result will be [A, B]. And so on. + * + * @return {string[]} base64-encoded curve25519 keys, from oldest to newest. + */ + getForwardingCurve25519KeyChain: function() { + return this._forwardingCurve25519KeyChain; + }, + + getUnsigned: function() { + return this.event.unsigned || {}; + }, + + unmarkLocallyRedacted: function() { + 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; + } + this.emit("Event.beforeRedaction", this, redactionEvent); + this._localRedactionEvent = redactionEvent; + if (!this.event.unsigned) { + this.event.unsigned = {}; + } + this.event.unsigned.redacted_because = redactionEvent.event; + }, + + /** + * 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 + * event causing the redaction + */ + makeRedacted: function(redaction_event) { + // quick sanity-check + if (!redaction_event.event) { + throw new Error("invalid redaction_event in makeRedacted"); + } + + this._localRedactionEvent = null; + + this.emit("Event.beforeRedaction", this, redaction_event); + + this._replacingEvent = null; + // we attempt to replicate what we would see from the server if + // the event had been redacted before we saw it. + // + // The server removes (most of) the content of the event, and adds a + // "redacted_because" key to the unsigned section containing the + // redacted event. + if (!this.event.unsigned) { + this.event.unsigned = {}; + } + this.event.unsigned.redacted_because = redaction_event.event; + + let key; + for (key in this.event) { + if (!this.event.hasOwnProperty(key)) { + continue; + } + if (!_REDACT_KEEP_KEY_MAP[key]) { + delete this.event[key]; + } + } + + const keeps = _REDACT_KEEP_CONTENT_MAP[this.getType()] || {}; + const content = this.getContent(); + for (key in content) { + if (!content.hasOwnProperty(key)) { + continue; + } + if (!keeps[key]) { + delete content[key]; + } + } + }, + + /** + * Check if this event has been redacted + * + * @return {boolean} True if this event has been redacted + */ + isRedacted: function() { + 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() { + return this.getType() === "m.room.redaction"; + }, + + /** + * Get the push actions, if known, for this event + * + * @return {?Object} push actions + */ + getPushActions: function() { + return this._pushActions; + }, + + /** + * Set the push actions for this event. + * + * @param {Object} pushActions push actions + */ + setPushActions: function(pushActions) { + 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) { + const oldUnsigned = this.getUnsigned(); + const oldId = this.getId(); + this.event = event; + // if this event was redacted before it was sent, it's locally marked as redacted. + // At this point, we've received the remote echo for the event, but not yet for + // the redaction that we are sending ourselves. Preserve the locally redacted + // state by copying over redacted_because so we don't get a flash of + // redacted, not-redacted, redacted as remote echos come in + if (oldUnsigned.redacted_because) { + if (!this.event.unsigned) { + this.event.unsigned = {}; + } + this.event.unsigned.redacted_because = oldUnsigned.redacted_because; + } + // successfully sent. + this.setStatus(null); + if (this.getId() !== oldId) { + // emit the event if it changed + this.emit("Event.localEventIdReplaced", this); + } + }, + + /** + * Whether the event is in any phase of sending, send failure, waiting for + * remote echo, etc. + * + * @return {boolean} + */ + isSending() { + return !!this.status; + }, + + /** + * Update the event's sending status and emit an event as well. + * + * @param {String} status The new status + */ + setStatus(status) { + this.status = status; + this.emit("Event.status", this, status); + }, + + replaceLocalEventId(eventId) { + this.event.event_id = eventId; + this.emit("Event.localEventIdReplaced", this); + }, + + /** + * Get whether the event is a relation event, and of a given type if + * `relType` is passed in. + * + * @param {string?} relType if given, checks that the relation is of the + * given type + * @return {boolean} + */ + isRelation(relType = undefined) { + // 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() { + 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. + * + * @param {MatrixEvent?} newEvent the event with the replacing content, if any. + */ + makeReplaced(newEvent) { + // 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 + // cancelled, which should be reflected on the target event. + if (this.isRedacted() && newEvent) { + return; + } + if (this._replacingEvent !== newEvent) { + 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), + * or else the status of the event. + * + * @return {EventStatus} + */ + getAssociatedStatus() { + if (this._replacingEvent) { + return this._replacingEvent.status; + } else if (this._localRedactionEvent) { + return this._localRedactionEvent.status; + } + return this.status; + }, + + getServerAggregatedRelation(relType) { + 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"); + 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. + * Replacements are aggregated on the server, so this would only + * return an event in case it came down the sync, or for local echo of edits. + * + * @return {MatrixEvent?} + */ + replacingEvent() { + 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"); + if (replaceRelation) { + const ts = replaceRelation.origin_server_ts; + if (Number.isFinite(ts)) { + return new Date(ts); + } + } 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() { + return this._localRedactionEvent; + }, + + /** + * For relations and redactions, returns the event_id this event is referring to. + * + * @return {string?} + */ + getAssociatedId() { + 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} + */ + hasAssocation() { + return !!this.getAssociatedId(); + }, + + /** + * Update the related id with a new one. + * + * Used to replace a local id with remote one before sending + * an event with a related id. + * + * @param {string} eventId the new event id + */ + updateAssociatedId(eventId) { + 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 + * request event in the same sync transaction may be flagged as cancelled to warn + * 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) { + 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() { + return this._isCancelled; + }, + + /** + * 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 + * with `JSON.stringify` which checks objects for functions named `toJSON` + * and will call them to customise the output if they are defined. + * + * @return {Object} + */ + toJSON() { + const event = { + type: this.getType(), + sender: this.getSender(), + content: this.getContent(), + event_id: this.getId(), + origin_server_ts: this.getTs(), + unsigned: this.getUnsigned(), + room_id: this.getRoomId(), + }; + + // if this is a redaction then attach the redacts key + if (this.isRedaction()) { + event.redacts = this.event.redacts; + } + + if (!this.isEncrypted()) { + return event; + } + + return { + decrypted: event, + encrypted: this.event, + }; + }, +}); + + +/* _REDACT_KEEP_KEY_MAP 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 + * + * Also: + * - 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 = [ + '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 = { + '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.aliases': {'aliases': 1}, +}; + + +/** + * Fires when an event is decrypted + * + * @event module:models/event.MatrixEvent#"Event.decrypted" + * + * @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. + */ diff --git a/matrix-js-sdk/src/models/group.js b/matrix-js-sdk/src/models/group.js new file mode 100644 index 000000000..9a4b9e989 --- /dev/null +++ b/matrix-js-sdk/src/models/group.js @@ -0,0 +1,95 @@ +/* +Copyright 2017 New Vector 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. +*/ + +/** + * @module models/group + */ +const EventEmitter = require("events").EventEmitter; + +const utils = require("../utils"); + +/** + * Construct a new Group. + * + * @param {string} groupId The ID of this group. + * + * @prop {string} groupId The ID of this group. + * @prop {string} name The human-readable display name for this group. + * @prop {string} avatarUrl The mxc URL for this group's avatar. + * @prop {string} myMembership The logged in user's membership of this group + * @prop {Object} inviter Infomation about the user who invited the logged in user + * to the group, if myMembership is 'invite'. + * @prop {string} inviter.userId The user ID of the inviter + */ +function Group(groupId) { + this.groupId = groupId; + this.name = null; + this.avatarUrl = null; + this.myMembership = null; + this.inviter = null; +} +utils.inherits(Group, EventEmitter); + +Group.prototype.setProfile = function(name, avatarUrl) { + if (this.name === name && this.avatarUrl === avatarUrl) return; + + this.name = name || this.groupId; + this.avatarUrl = avatarUrl; + + this.emit("Group.profile", this); +}; + +Group.prototype.setMyMembership = function(membership) { + if (this.myMembership === membership) return; + + this.myMembership = membership; + + this.emit("Group.myMembership", this); +}; + +/** + * Sets the 'inviter' property. This does not emit an event (the inviter + * will only change when the user is revited / reinvited to a room), + * so set this before setting myMembership. + * @param {Object} inviter Infomation about who invited us to the room + */ +Group.prototype.setInviter = function(inviter) { + this.inviter = inviter; +}; + +module.exports = Group; + +/** + * Fires whenever a group's profile information is updated. + * This means the 'name' and 'avatarUrl' properties. + * @event module:client~MatrixClient#"Group.profile" + * @param {Group} group The group whose profile was updated. + * @example + * matrixClient.on("Group.profile", function(group){ + * var name = group.name; + * }); + */ + +/** + * Fires whenever the logged in user's membership status of + * the group is updated. + * @event module:client~MatrixClient#"Group.myMembership" + * @param {Group} group The group in which the user's membership changed + * @example + * matrixClient.on("Group.myMembership", function(group){ + * var myMembership = group.myMembership; + * }); + */ diff --git a/matrix-js-sdk/src/models/relations.js b/matrix-js-sdk/src/models/relations.js new file mode 100644 index 000000000..7032eeaad --- /dev/null +++ b/matrix-js-sdk/src/models/relations.js @@ -0,0 +1,344 @@ +/* +Copyright 2019 New Vector 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. +*/ + +import EventEmitter from 'events'; +import { EventStatus } from '../../lib/models/event'; + +/** + * A container for relation events that supports easy access to common ways of + * aggregating such events. Each instance holds events that of a single relation + * type and event type. All of the events also relate to the same original event. + * + * The typical way to get one of these containers is via + * EventTimelineSet#getRelationsForEvent. + */ +export default class Relations extends EventEmitter { + /** + * @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. + * @param {?Room} room + * Room for this container. May be null for non-room cases, such as the + * notification timeline. + */ + constructor(relationType, eventType, room) { + super(); + this.relationType = relationType; + this.eventType = eventType; + this._relations = new Set(); + this._annotationsByKey = new Map(); + this._annotationsBySender = new Map(); + this._sortedAnnotationsByKey = []; + this._targetEvent = null; + } + + /** + * Add relation events to this collection. + * + * @param {MatrixEvent} event + * The new relation event to be added. + */ + addEvent(event) { + if (this._relations.has(event)) { + return; + } + + const relation = event.getRelation(); + if (!relation) { + console.error("Event must have relation info"); + return; + } + + const relationType = relation.rel_type; + const eventType = event.getType(); + + if (this.relationType !== relationType || this.eventType !== eventType) { + console.error("Event relation info doesn't match this container"); + return; + } + + // 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); + } + + this._relations.add(event); + + if (this.relationType === "m.annotation") { + this._addAnnotationToAggregation(event); + } else if (this.relationType === "m.replace" && this._targetEvent) { + this._targetEvent.makeReplaced(this.getLastReplacement()); + } + + event.on("Event.beforeRedaction", this._onBeforeRedaction); + + this.emit("Relations.add", event); + } + + /** + * Remove relation event from this collection. + * + * @param {MatrixEvent} event + * The relation event to remove. + */ + _removeEvent(event) { + if (!this._relations.has(event)) { + return; + } + + const relation = event.getRelation(); + if (!relation) { + console.error("Event must have relation info"); + return; + } + + const relationType = relation.rel_type; + const eventType = event.getType(); + + if (this.relationType !== relationType || this.eventType !== eventType) { + console.error("Event relation info doesn't match this container"); + return; + } + + this._relations.delete(event); + + if (this.relationType === "m.annotation") { + this._removeAnnotationFromAggregation(event); + } else if (this.relationType === "m.replace" && this._targetEvent) { + this._targetEvent.makeReplaced(this.getLastReplacement()); + } + + this.emit("Relations.remove", event); + } + + /** + * Listens for event status changes to remove cancelled events. + * + * @param {MatrixEvent} event The event whose status has changed + * @param {EventStatus} status The new status + */ + _onEventStatus = (event, status) => { + if (!event.isSending()) { + // Sending is done, so we don't need to listen anymore + 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); + } + + /** + * Get all relation events in this collection. + * + * These are currently in the order of insertion to this collection, which + * won't match timeline order in the case of scrollback. + * TODO: Tweak `addEvent` to insert correctly for scrollback. + * + * @return {Array} + * Relation events in insertion order. + */ + getRelations() { + return [...this._relations]; + } + + _addAnnotationToAggregation(event) { + const { key } = event.getRelation(); + if (!key) { + return; + } + + let eventsForKey = this._annotationsByKey.get(key); + if (!eventsForKey) { + eventsForKey = new Set(); + this._annotationsByKey.set(key, eventsForKey); + 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) => { + const aEvents = a[1]; + const bEvents = b[1]; + return bEvents.size - aEvents.size; + }); + + const sender = event.getSender(); + let eventsFromSender = this._annotationsBySender.get(sender); + if (!eventsFromSender) { + eventsFromSender = new Set(); + this._annotationsBySender.set(sender, eventsFromSender); + } + // Add the new event to the set for this sender + eventsFromSender.add(event); + } + + _removeAnnotationFromAggregation(event) { + const { key } = event.getRelation(); + if (!key) { + return; + } + + const eventsForKey = this._annotationsByKey.get(key); + if (eventsForKey) { + eventsForKey.delete(event); + + // Re-sort the [key, events] pairs in descending order of event count + this._sortedAnnotationsByKey.sort((a, b) => { + const aEvents = a[1]; + const bEvents = b[1]; + return bEvents.size - aEvents.size; + }); + } + + const sender = event.getSender(); + const eventsFromSender = this._annotationsBySender.get(sender); + if (eventsFromSender) { + eventsFromSender.delete(event); + } + } + + /** + * For relations that have been redacted, we want to remove them from + * aggregation data sets and emit an update event. + * + * To do so, we listen for `Event.beforeRedaction`, which happens: + * - after the server accepted the redaction and remote echoed back to us + * - before the original event has been marked redacted in the client + * + * @param {MatrixEvent} redactedEvent + * The original relation event that is about to be redacted. + */ + _onBeforeRedaction = (redactedEvent) => { + if (!this._relations.has(redactedEvent)) { + return; + } + + this._relations.delete(redactedEvent); + + if (this.relationType === "m.annotation") { + // Remove the redacted annotation from aggregation by key + this._removeAnnotationFromAggregation(redactedEvent); + } else if (this.relationType === "m.replace" && this._targetEvent) { + this._targetEvent.makeReplaced(this.getLastReplacement()); + } + + redactedEvent.removeListener("Event.beforeRedaction", this._onBeforeRedaction); + + this.emit("Relations.redaction"); + } + + /** + * Get all events in this collection grouped by key and sorted by descending + * event count in each group. + * + * This is currently only supported for the annotation relation type. + * + * @return {Array} + * 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") { + // Other relation types are not grouped currently. + return null; + } + + return this._sortedAnnotationsByKey; + } + + /** + * Get all events in this collection grouped by sender. + * + * This is currently only supported for the annotation relation type. + * + * @return {Map} + * 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") { + // Other relation types are not grouped currently. + return null; + } + + return this._annotationsBySender; + } + + /** + * Returns the most recent (and allowed) m.replace relation, if any. + * + * This is currently only supported for the m.replace relation type, + * once the target event is known, see `addEvent`. + * + * @return {MatrixEvent?} + */ + getLastReplacement() { + if (this.relationType !== "m.replace") { + // Aggregating on last only makes sense for this relation type + return null; + } + if (!this._targetEvent) { + // Don't know which replacements to accept yet. + // This method shouldn't be called before the original + // event is known anyway. + return null; + } + + // 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"); + const minTs = replaceRelation && replaceRelation.origin_server_ts; + + return this.getRelations().reduce((last, event) => { + if (event.getSender() !== this._targetEvent.getSender()) { + return last; + } + if (minTs && minTs > event.getTs()) { + return last; + } + if (last && last.getTs() > event.getTs()) { + return last; + } + return event; + }, null); + } + + /* + * @param {MatrixEvent} targetEvent the event the relations are related to. + */ + setTargetEvent(event) { + if (this._targetEvent) { + return; + } + this._targetEvent = event; + if (this.relationType === "m.replace") { + const replacement = 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); + } + } + } +} diff --git a/matrix-js-sdk/src/models/room-member.js b/matrix-js-sdk/src/models/room-member.js new file mode 100644 index 000000000..d92267720 --- /dev/null +++ b/matrix-js-sdk/src/models/room-member.js @@ -0,0 +1,380 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; +/** + * @module models/room-member + */ +const EventEmitter = require("events").EventEmitter; +const ContentRepo = require("../content-repo"); + +const utils = require("../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. + */ +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(); +} +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) { + if (event.getType() !== "m.room.member") { + return; + } + + this._isOutOfBand = false; + + this.events.member = event; + + const oldMembership = this.membership; + this.membership = event.getDirectionalContent().membership; + + const oldName = this.name; + this.name = calculateDisplayName( + this.userId, + event.getDirectionalContent().displayname, + roomState); + + 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; + utils.forEach(utils.values(evContent.users), function(lvl) { + maxLevel = Math.max(maxLevel, lvl); + }); + const oldPowerLevel = this.powerLevel; + const oldPowerLevelNorm = this.powerLevelNorm; + + if (evContent.users && evContent.users[this.userId] !== undefined) { + this.powerLevel = evContent.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 (!utils.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 = ContentRepo.getHttpUriForMxc( + baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks, + ); + if (httpUrl) { + return httpUrl; + } else if (allowDefault) { + return ContentRepo.getIdenticonUri( + baseUrl, this.userId, width, height, + ); + } + 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; +}; + +function calculateDisplayName(selfUserId, displayName, roomState) { + 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 (!roomState) { + return displayName; + } + + // 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 + // Also show mxid if there are other people with the same or similar + // displayname, after hidden character removal. + let disambiguate = /@.+:.+/.test(displayName); + if (!disambiguate) { + const userIds = roomState.getUserIdsWithDisplayName(displayName); + disambiguate = userIds.some((u) => u !== selfUserId); + } + + if (disambiguate) { + return displayName + " (" + selfUserId + ")"; + } + return displayName; +} + +/** + * The RoomMember class. + */ +module.exports = RoomMember; + +/** + * 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/matrix-js-sdk/src/models/room-state.js b/matrix-js-sdk/src/models/room-state.js new file mode 100644 index 000000000..b8c1d9197 --- /dev/null +++ b/matrix-js-sdk/src/models/room-state.js @@ -0,0 +1,813 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; +/** + * @module models/room-state + */ +const EventEmitter = require("events").EventEmitter; + +const utils = require("../utils"); +const RoomMember = require("./room-member"); +import logger from '../../src/logger'; + +// 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. + */ +function RoomState(roomId, oobMemberFlags = undefined) { + this.roomId = roomId; + this.members = { + // userId: RoomMember + }; + + /** + * Map event type → Map state key → MatrixEvent + * @type Map> + */ + this.events = new 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 = new Map(); + 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 utils.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 utils.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 utils.values(this.events.get(eventType)); + } + 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; + + this.events.forEach((eventsByStateKey) => { + const eventsForType = eventsByStateKey.values(); + copy.setStateEvents(eventsForType); + }); + + // 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.get(event.getType()) === undefined || + this.events.get(event.getType()).get(event.getStateKey()) === undefined; + }); + + 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 + utils.forEach(stateEvents, function(event) { + if (event.getRoomId() !== self.roomId) { + return; + } + if (!event.isState()) { + return; + } + + 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); + }); + + // 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). + utils.forEach(stateEvents, 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") { + const members = utils.values(self.members); + utils.forEach(members, function(member) { + member.setPowerLevelEvent(event); + 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.get(event.getType()) === undefined) { + this.events.set(event.getType(), new Map()); + } + this.events.get(event.getType()) + .set(event.getStateKey(), event); +}; + +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) { + utils.forEach(utils.values(this.members), 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.get(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; +}; + +/** + * The RoomState class. + */ +module.exports = RoomState; + + +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.get(strippedOldName); + if (existingUserIds) { + // remove this user ID from this array + const filteredUserIDs = existingUserIds.filter((id) => id !== userId); + roomState._displayNameToUserIds.set(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.has(strippedDisplayname)) { + roomState._displayNameToUserIds.set(strippedDisplayname, []); + } + roomState._displayNameToUserIds.get(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. + * @example + * matrixClient.on("RoomState.events", function(event, state){ + * 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/matrix-js-sdk/src/models/room-summary.js b/matrix-js-sdk/src/models/room-summary.js new file mode 100644 index 000000000..7329c3e79 --- /dev/null +++ b/matrix-js-sdk/src/models/room-summary.js @@ -0,0 +1,42 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; +/** + * @module models/room-summary + */ + +/** + * 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. + * @constructor + * @param {string} roomId Required. The ID of this room. + * @param {Object} info Optional. The summary info. Additional keys are supported. + * @param {string} info.title The title of the room (e.g. m.room.name) + * @param {string} info.desc The description of the room (e.g. + * m.room.topic) + * @param {Number} info.numMembers The number of joined users. + * @param {string[]} info.aliases The list of aliases for this room. + * @param {Number} info.timestamp The timestamp for this room. + */ +function RoomSummary(roomId, info) { + this.roomId = roomId; + this.info = info; +} + +/** + * The RoomSummary class. + */ +module.exports = RoomSummary; diff --git a/matrix-js-sdk/src/models/room.js b/matrix-js-sdk/src/models/room.js new file mode 100644 index 000000000..0edaa14c8 --- /dev/null +++ b/matrix-js-sdk/src/models/room.js @@ -0,0 +1,1987 @@ +/* +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. +*/ +"use strict"; +/** + * @module models/room + */ +const EventEmitter = require("events").EventEmitter; + +const EventStatus = require("./event").EventStatus; +const RoomSummary = require("./room-summary"); +const RoomMember = require("./room-member"); +const MatrixEvent = require("./event").MatrixEvent; +const utils = require("../utils"); +const ContentRepo = require("../content-repo"); +const EventTimeline = require("./event-timeline"); +const EventTimelineSet = require("./event-timeline-set"); + +import logger from '../../src/logger'; +import ReEmitter from '../ReEmitter'; + +// 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 = '4'; +const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4']; + +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 {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. + */ +function Room(roomId, client, myUserId, opts) { + opts = opts || {}; + opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; + + 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 }, + }; + /** + * Map event type → event + * @type Map + */ + this.accountData = new Map(); + 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 = []; + } + + // 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 + + this._client = client; + if (!this._opts.lazyLoadMembers) { + this._membersPromise = Promise.resolve(); + } else { + this._membersPromise = null; + } +} + +utils.inherits(Room, EventEmitter); + +/** + * 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) { + logger.warn("Room " + this.room_id + " does not have an m.room.create event"); + 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; +}; + +/** + * Check whether the pending event list contains a given event by ID. + * + * @param {string} eventId The event ID to check for. + * @return {boolean} + * @throws If opts.pendingEventOrdering was not 'detached' + */ +Room.prototype.hasPendingEvent = function(eventId) { + if (this._opts.pendingEventOrdering !== "detached") { + throw new Error( + "Cannot call hasPendingEvent with pendingEventOrdering == " + + this._opts.pendingEventOrdering); + } + + return this._pendingEventList.some(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(); +}; + +/** + * @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 = await 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("m.room.avatar", ""); + if (allowDefault === undefined) { + allowDefault = true; + } + if (!roomAvatarEvent && !allowDefault) { + return null; + } + + const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null; + if (mainUrl) { + return ContentRepo.getHttpUriForMxc( + baseUrl, mainUrl, width, height, resizeMethod, + ); + } else if (allowDefault) { + return ContentRepo.getIdenticonUri( + baseUrl, this.roomId, width, height, + ); + } + + return 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 (utils.isArray(aliasEvent.getContent().aliases)) { + Array.prototype.push.apply( + aliasStrings, aliasEvent.getContent().aliases, + ); + } + } + } + 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; + } + return null; +}; + +/** + * 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 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 utils.filter(this.currentState.getMembers(), 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' + * @fires module:client~MatrixClient#event:"Room.timeline" + * @private + */ +Room.prototype._addLiveEvent = function(event, duplicateStrategy) { + 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); + 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); + } + + // 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) { + 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); + + 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 (this._filter.filterRoomTimeline([event]).length) { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); + } + } else { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); + } + } + } + + this.emit("Room.localEchoUpdated", event, this, null, null); +}; +/** + * 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 (this._filter.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; + + // no longer pending + delete this._txnToEvent[remoteEvent.getUnsigned().transaction_id]; + + // if it's in the pending list, remove it + if (this._pendingEventList) { + utils.removeElement( + this._pendingEventList, + function(ev) { + return ev.getId() == oldEventId; + }, false, + ); + } + + // 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()}`); + + // 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.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. + * + * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. + */ +Room.prototype.addLiveEvents = function(events, duplicateStrategy) { + 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); + } +}; + +/** + * 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 || []; + utils.forEach(strippedStateEvents, 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.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; + utils.keys(event.getContent()).forEach(function(eventId) { + utils.keys(event.getContent()[eventId]).forEach(function(receiptType) { + utils.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 = {}; + utils.keys(receipts).forEach(function(receiptType) { + utils.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); + } + this.accountData.set(event.getType(), event); + this.emit("Room.accountData", event, this); + } +}; + +/** + * 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.get(type); +}; + + +/** + * Returns wheter 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); +}; + +/** + * 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.getAliases(); + + 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`; + } + } +} + +/** + * The Room class. + */ +module.exports = Room; + +/** + * 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. + * @example + * matrixClient.on("Room.accountData", function(event, room){ + * 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. + */ diff --git a/matrix-js-sdk/src/models/search-result.js b/matrix-js-sdk/src/models/search-result.js new file mode 100644 index 000000000..bab76fa83 --- /dev/null +++ b/matrix-js-sdk/src/models/search-result.js @@ -0,0 +1,66 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; + +/** + * @module models/search-result + */ + +const EventContext = require("./event-context"); +const utils = require("../utils"); + +/** + * Construct a new SearchResult + * + * @param {number} rank where this SearchResult ranks in the results + * @param {event-context.EventContext} eventContext the matching event and its + * context + * + * @constructor + */ +function SearchResult(rank, eventContext) { + this.rank = rank; + this.context = eventContext; +} + +/** + * Create a SearchResponse from the response to /search + * @static + * @param {Object} jsonObj + * @param {function} eventMapper + * @return {SearchResult} + */ + +SearchResult.fromJson = function(jsonObj, eventMapper) { + const jsonContext = jsonObj.context || {}; + const events_before = jsonContext.events_before || []; + const events_after = jsonContext.events_after || []; + + const context = new EventContext(eventMapper(jsonObj.result)); + + context.setPaginateToken(jsonContext.start, true); + context.addEvents(utils.map(events_before, eventMapper), true); + context.addEvents(utils.map(events_after, eventMapper), false); + context.setPaginateToken(jsonContext.end, false); + + return new SearchResult(jsonObj.rank, context); +}; + + +/** + * The SearchResult class + */ +module.exports = SearchResult; diff --git a/matrix-js-sdk/src/models/user.js b/matrix-js-sdk/src/models/user.js new file mode 100644 index 000000000..5e6340f29 --- /dev/null +++ b/matrix-js-sdk/src/models/user.js @@ -0,0 +1,257 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; +/** + * @module models/user + */ + const EventEmitter = require("events").EventEmitter; + const utils = require("../utils"); + +/** + * 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. + */ +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; + this.displayName = name; + 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) { + this.rawDisplayName = name; +}; + + +/** + * 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); +}; + +/** + * The User class. + */ +module.exports = User; + +/** + * 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/matrix-js-sdk/src/pushprocessor.js b/matrix-js-sdk/src/pushprocessor.js new file mode 100644 index 000000000..54202b412 --- /dev/null +++ b/matrix-js-sdk/src/pushprocessor.js @@ -0,0 +1,471 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector 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. +*/ + +import {escapeRegExp, globToRegexp} from "./utils"; + +/** + * @module pushprocessor + */ + +const RULEKINDS_IN_ORDER = ['override', 'content', 'room', 'sender', 'underride']; + +// The default override rules to apply when calculating actions for an event. These +// defaults apply under no other circumstances to avoid confusing the client with server +// state. We do this for two reasons: +// 1. Synapse is unlikely to send us the push rule in an incremental sync - see +// https://github.com/matrix-org/synapse/pull/4867#issuecomment-481446072 for +// more details. +// 2. We often want to start using push rules ahead of the server supporting them, +// and so we can put them here. +const DEFAULT_OVERRIDE_RULES = [ + { + // For homeservers which don't support MSC1930 yet + rule_id: ".m.rule.tombstone", + default: true, + enabled: true, + conditions: [ + { + kind: "event_match", + key: "type", + pattern: "m.room.tombstone", + }, + ], + actions: [ + "notify", + { + set_tweak: "highlight", + value: true, + }, + ], + }, + { + // For homeservers which don't support MSC2153 yet + rule_id: ".m.rule.reaction", + default: true, + enabled: true, + conditions: [ + { + kind: "event_match", + key: "type", + pattern: "m.reaction", + }, + ], + actions: [ + "dont_notify", + ], + }, +]; + +/** + * Construct a Push Processor. + * @constructor + * @param {Object} client The Matrix client object to use + */ +function PushProcessor(client) { + const cachedGlobToRegex = { + // $glob: RegExp, + }; + + const matchingRuleFromKindSet = (ev, kindset, device) => { + for (let ruleKindIndex = 0; + ruleKindIndex < RULEKINDS_IN_ORDER.length; + ++ruleKindIndex) { + const kind = RULEKINDS_IN_ORDER[ruleKindIndex]; + const ruleset = kindset[kind]; + + for (let ruleIndex = 0; ruleIndex < ruleset.length; ++ruleIndex) { + const rule = ruleset[ruleIndex]; + if (!rule.enabled) { + continue; + } + + const rawrule = templateRuleToRaw(kind, rule, device); + if (!rawrule) { + continue; + } + + if (this.ruleMatchesEvent(rawrule, ev)) { + rule.kind = kind; + return rule; + } + } + } + return null; + }; + + const templateRuleToRaw = function(kind, tprule, device) { + const rawrule = { + 'rule_id': tprule.rule_id, + 'actions': tprule.actions, + 'conditions': [], + }; + switch (kind) { + case 'underride': + case 'override': + rawrule.conditions = tprule.conditions; + break; + case 'room': + if (!tprule.rule_id) { + return null; + } + rawrule.conditions.push({ + 'kind': 'event_match', + 'key': 'room_id', + 'value': tprule.rule_id, + }); + break; + case 'sender': + if (!tprule.rule_id) { + return null; + } + rawrule.conditions.push({ + 'kind': 'event_match', + 'key': 'user_id', + 'value': tprule.rule_id, + }); + break; + case 'content': + if (!tprule.pattern) { + return null; + } + rawrule.conditions.push({ + 'kind': 'event_match', + 'key': 'content.body', + 'pattern': tprule.pattern, + }); + break; + } + if (device) { + rawrule.conditions.push({ + 'kind': 'device', + 'profile_tag': device, + }); + } + return rawrule; + }; + + const eventFulfillsCondition = function(cond, ev) { + const condition_functions = { + "event_match": eventFulfillsEventMatchCondition, + "device": eventFulfillsDeviceCondition, + "contains_display_name": eventFulfillsDisplayNameCondition, + "room_member_count": eventFulfillsRoomMemberCountCondition, + "sender_notification_permission": eventFulfillsSenderNotifPermCondition, + }; + if (condition_functions[cond.kind]) { + return condition_functions[cond.kind](cond, ev); + } + // unknown conditions: we previously matched all unknown conditions, + // but given that rules can be added to the base rules on a server, + // it's probably better to not match unknown conditions. + return false; + }; + + const eventFulfillsSenderNotifPermCondition = function(cond, ev) { + const notifLevelKey = cond['key']; + if (!notifLevelKey) { + return false; + } + + const room = client.getRoom(ev.getRoomId()); + if (!room || !room.currentState) { + return false; + } + + // Note that this should not be the current state of the room but the state at + // the point the event is in the DAG. Unfortunately the js-sdk does not store + // this. + return room.currentState.mayTriggerNotifOfType(notifLevelKey, ev.getSender()); + }; + + const eventFulfillsRoomMemberCountCondition = function(cond, ev) { + if (!cond.is) { + return false; + } + + const room = client.getRoom(ev.getRoomId()); + if (!room || !room.currentState || !room.currentState.members) { + return false; + } + + const memberCount = room.currentState.getJoinedMemberCount(); + + const m = cond.is.match(/^([=<>]*)([0-9]*)$/); + if (!m) { + return false; + } + const ineq = m[1]; + const rhs = parseInt(m[2]); + if (isNaN(rhs)) { + return false; + } + switch (ineq) { + case '': + case '==': + return memberCount == rhs; + case '<': + return memberCount < rhs; + case '>': + return memberCount > rhs; + case '<=': + return memberCount <= rhs; + case '>=': + return memberCount >= rhs; + default: + return false; + } + }; + + const eventFulfillsDisplayNameCondition = function(cond, ev) { + let content = ev.getContent(); + if (ev.isEncrypted() && ev.getClearContent()) { + content = ev.getClearContent(); + } + if (!content || !content.body || typeof content.body != 'string') { + return false; + } + + const room = client.getRoom(ev.getRoomId()); + if (!room || !room.currentState || !room.currentState.members || + !room.currentState.getMember(client.credentials.userId)) { + return false; + } + + const displayName = room.currentState.getMember(client.credentials.userId).name; + + // N.B. we can't use \b as it chokes on unicode. however \W seems to be okay + // as shorthand for [^0-9A-Za-z_]. + const pat = new RegExp("(^|\\W)" + escapeRegExp(displayName) + "(\\W|$)", 'i'); + return content.body.search(pat) > -1; + }; + + const eventFulfillsDeviceCondition = function(cond, ev) { + return false; // XXX: Allow a profile tag to be set for the web client instance + }; + + const eventFulfillsEventMatchCondition = function(cond, ev) { + if (!cond.key) { + return false; + } + + const val = valueForDottedKey(cond.key, ev); + if (!val || typeof val != 'string') { + return false; + } + + if (cond.value) { + return cond.value === val; + } + + let regex; + + if (cond.key == 'content.body') { + regex = createCachedRegex('(^|\\W)', cond.pattern, '(\\W|$)'); + } else { + regex = createCachedRegex('^', cond.pattern, '$'); + } + + return !!val.match(regex); + }; + + const createCachedRegex = function(prefix, glob, suffix) { + if (cachedGlobToRegex[glob]) { + return cachedGlobToRegex[glob]; + } + cachedGlobToRegex[glob] = new RegExp( + prefix + globToRegexp(glob) + suffix, + 'i', // Case insensitive + ); + return cachedGlobToRegex[glob]; + }; + + const valueForDottedKey = function(key, ev) { + const parts = key.split('.'); + let val; + + // special-case the first component to deal with encrypted messages + const firstPart = parts[0]; + if (firstPart == 'content') { + val = ev.getContent(); + parts.shift(); + } else if (firstPart == 'type') { + val = ev.getType(); + parts.shift(); + } else { + // use the raw event for any other fields + val = ev.event; + } + + while (parts.length > 0) { + const thispart = parts.shift(); + if (!val[thispart]) { + return null; + } + val = val[thispart]; + } + return val; + }; + + const matchingRuleForEventWithRulesets = function(ev, rulesets) { + if (!rulesets || !rulesets.device) { + return null; + } + if (ev.getSender() == client.credentials.userId) { + return null; + } + + const allDevNames = Object.keys(rulesets.device); + for (let i = 0; i < allDevNames.length; ++i) { + const devname = allDevNames[i]; + const devrules = rulesets.device[devname]; + + const matchingRule = matchingRuleFromKindSet(devrules, devname); + if (matchingRule) { + return matchingRule; + } + } + return matchingRuleFromKindSet(ev, rulesets.global); + }; + + const pushActionsForEventAndRulesets = function(ev, rulesets) { + const rule = matchingRuleForEventWithRulesets(ev, rulesets); + if (!rule) { + return {}; + } + + const actionObj = PushProcessor.actionListToActionsObject(rule.actions); + + // Some actions are implicit in some situations: we add those here + if (actionObj.tweaks.highlight === undefined) { + // if it isn't specified, highlight if it's a content + // rule but otherwise not + actionObj.tweaks.highlight = (rule.kind == 'content'); + } + + return actionObj; + }; + + const applyRuleDefaults = function(clientRuleset) { + // Deep clone the object before we mutate it + const ruleset = JSON.parse(JSON.stringify(clientRuleset)); + + if (!clientRuleset['global']) { + clientRuleset['global'] = {}; + } + if (!clientRuleset['global']['override']) { + clientRuleset['global']['override'] = []; + } + + // Apply default overrides + const globalOverrides = clientRuleset['global']['override']; + for (const override of DEFAULT_OVERRIDE_RULES) { + const existingRule = globalOverrides + .find((r) => r.rule_id === override.rule_id); + + if (!existingRule) { + const ruleId = override.rule_id; + console.warn(`Adding default global override for ${ruleId}`); + globalOverrides.push(override); + } + } + + return ruleset; + }; + + this.ruleMatchesEvent = function(rule, ev) { + let ret = true; + for (let i = 0; i < rule.conditions.length; ++i) { + const cond = rule.conditions[i]; + ret &= eventFulfillsCondition(cond, ev); + } + //console.log("Rule "+rule.rule_id+(ret ? " matches" : " doesn't match")); + return ret; + }; + + + /** + * Get the user's push actions for the given event + * + * @param {module:models/event.MatrixEvent} ev + * + * @return {PushAction} + */ + this.actionsForEvent = function(ev) { + const rules = applyRuleDefaults(client.pushRules); + return pushActionsForEventAndRulesets(ev, rules); + }; + + /** + * Get one of the users push rules by its ID + * + * @param {string} ruleId The ID of the rule to search for + * @return {object} The push rule, or null if no such rule was found + */ + this.getPushRuleById = function(ruleId) { + for (const scope of ['device', 'global']) { + if (client.pushRules[scope] === undefined) continue; + + for (const kind of RULEKINDS_IN_ORDER) { + if (client.pushRules[scope][kind] === undefined) continue; + + for (const rule of client.pushRules[scope][kind]) { + if (rule.rule_id === ruleId) return rule; + } + } + } + return null; + }; +} + +/** + * Convert a list of actions into a object with the actions as keys and their values + * eg. [ 'notify', { set_tweak: 'sound', value: 'default' } ] + * becomes { notify: true, tweaks: { sound: 'default' } } + * @param {array} actionlist The actions list + * + * @return {object} A object with key 'notify' (true or false) and an object of actions + */ +PushProcessor.actionListToActionsObject = function(actionlist) { + const actionobj = { 'notify': false, 'tweaks': {} }; + for (let i = 0; i < actionlist.length; ++i) { + const action = actionlist[i]; + if (action === 'notify') { + actionobj.notify = true; + } else if (typeof action === 'object') { + if (action.value === undefined) { + action.value = true; + } + actionobj.tweaks[action.set_tweak] = action.value; + } + } + return actionobj; +}; + +/** + * @typedef {Object} PushAction + * @type {Object} + * @property {boolean} notify Whether this event should notify the user or not. + * @property {Object} tweaks How this event should be notified. + * @property {boolean} tweaks.highlight Whether this event should be highlighted + * on the UI. + * @property {boolean} tweaks.sound Whether this notification should produce a + * noise. + */ + +/** The PushProcessor class. */ +module.exports = PushProcessor; + diff --git a/matrix-js-sdk/src/randomstring.js b/matrix-js-sdk/src/randomstring.js new file mode 100644 index 000000000..7ebe4ed78 --- /dev/null +++ b/matrix-js-sdk/src/randomstring.js @@ -0,0 +1,26 @@ +/* +Copyright 2018 New Vector 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. +*/ + +export function randomString(len) { + let ret = ""; + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for (let i = 0; i < len; ++i) { + ret += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + return ret; +} diff --git a/matrix-js-sdk/src/realtime-callbacks.js b/matrix-js-sdk/src/realtime-callbacks.js new file mode 100644 index 000000000..6e0e88910 --- /dev/null +++ b/matrix-js-sdk/src/realtime-callbacks.js @@ -0,0 +1,204 @@ +/* +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. +*/ + +/* A re-implementation of the javascript callback functions (setTimeout, + * clearTimeout; setInterval and clearInterval are not yet implemented) which + * try to improve handling of large clock jumps (as seen when + * suspending/resuming the system). + * + * In particular, if a timeout would have fired while the system was suspended, + * it will instead fire as soon as possible after resume. + */ + +"use strict"; +import logger from '../src/logger'; + +// we schedule a callback at least this often, to check if we've missed out on +// some wall-clock time due to being suspended. +const TIMER_CHECK_PERIOD_MS = 1000; + +// counter, for making up ids to return from setTimeout +let _count = 0; + +// the key for our callback with the real global.setTimeout +let _realCallbackKey; + +// a sorted list of the callbacks to be run. +// each is an object with keys [runAt, func, params, key]. +const _callbackList = []; + +// var debuglog = logger.log.bind(logger); +const debuglog = function() {}; + +/** + * Replace the function used by this module to get the current time. + * + * Intended for use by the unit tests. + * + * @param {function} f function which should return a millisecond counter + * + * @internal + */ +module.exports.setNow = function(f) { + _now = f || Date.now; +}; +let _now = Date.now; + +/** + * reimplementation of window.setTimeout, which will call the callback if + * the wallclock time goes past the deadline. + * + * @param {function} func callback to be called after a delay + * @param {Number} delayMs number of milliseconds to delay by + * + * @return {Number} an identifier for this callback, which may be passed into + * clearTimeout later. + */ +module.exports.setTimeout = function(func, delayMs) { + delayMs = delayMs || 0; + if (delayMs < 0) { + delayMs = 0; + } + + const params = Array.prototype.slice.call(arguments, 2); + const runAt = _now() + delayMs; + const key = _count++; + debuglog("setTimeout: scheduling cb", key, "at", runAt, + "(delay", delayMs, ")"); + const data = { + runAt: runAt, + func: func, + params: params, + key: key, + }; + + // figure out where it goes in the list + const idx = binarySearch( + _callbackList, function(el) { + return el.runAt - runAt; + }, + ); + + _callbackList.splice(idx, 0, data); + _scheduleRealCallback(); + + return key; +}; + +/** + * reimplementation of window.clearTimeout, which mirrors setTimeout + * + * @param {Number} key result from an earlier setTimeout call + */ +module.exports.clearTimeout = function(key) { + if (_callbackList.length === 0) { + return; + } + + // remove the element from the list + let i; + for (i = 0; i < _callbackList.length; i++) { + const cb = _callbackList[i]; + if (cb.key == key) { + _callbackList.splice(i, 1); + break; + } + } + + // iff it was the first one in the list, reschedule our callback. + if (i === 0) { + _scheduleRealCallback(); + } +}; + +// use the real global.setTimeout to schedule a callback to _runCallbacks. +function _scheduleRealCallback() { + if (_realCallbackKey) { + global.clearTimeout(_realCallbackKey); + } + + const first = _callbackList[0]; + + if (!first) { + debuglog("_scheduleRealCallback: no more callbacks, not rescheduling"); + return; + } + + const now = _now(); + const delayMs = Math.min(first.runAt - now, TIMER_CHECK_PERIOD_MS); + + debuglog("_scheduleRealCallback: now:", now, "delay:", delayMs); + _realCallbackKey = global.setTimeout(_runCallbacks, delayMs); +} + +function _runCallbacks() { + let cb; + const now = _now(); + debuglog("_runCallbacks: now:", now); + + // get the list of things to call + const callbacksToRun = []; + while (true) { + const first = _callbackList[0]; + if (!first || first.runAt > now) { + break; + } + cb = _callbackList.shift(); + debuglog("_runCallbacks: popping", cb.key); + callbacksToRun.push(cb); + } + + // reschedule the real callback before running our functions, to + // keep the codepaths the same whether or not our functions + // register their own setTimeouts. + _scheduleRealCallback(); + + for (let i = 0; i < callbacksToRun.length; i++) { + cb = callbacksToRun[i]; + try { + cb.func.apply(global, cb.params); + } catch (e) { + logger.error("Uncaught exception in callback function", + e.stack || e); + } + } +} + + +/* search in a sorted array. + * + * returns the index of the last element for which func returns + * greater than zero, or array.length if no such element exists. + */ +function binarySearch(array, func) { + // min is inclusive, max exclusive. + let min = 0, + max = array.length; + + while (min < max) { + const mid = (min + max) >> 1; + const res = func(array[mid]); + if (res > 0) { + // the element at 'mid' is too big; set it as the new max. + max = mid; + } else { + // the element at 'mid' is too small. 'min' is inclusive, so +1. + min = mid + 1; + } + } + // presumably, min==max now. + return min; +} diff --git a/matrix-js-sdk/src/scheduler.js b/matrix-js-sdk/src/scheduler.js new file mode 100644 index 000000000..a799597b9 --- /dev/null +++ b/matrix-js-sdk/src/scheduler.js @@ -0,0 +1,325 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; +/** + * This is an internal module which manages queuing, scheduling and retrying + * of requests. + * @module scheduler + */ +const utils = require("./utils"); +import Promise from 'bluebird'; +import logger from '../src/logger'; + +const DEBUG = false; // set true to enable console logging. + +/** + * Construct a scheduler for Matrix. Requires + * {@link module:scheduler~MatrixScheduler#setProcessFunction} to be provided + * with a way of processing events. + * @constructor + * @param {module:scheduler~retryAlgorithm} retryAlgorithm Optional. The retry + * algorithm to apply when determining when to try to send an event again. + * Defaults to {@link module:scheduler~MatrixScheduler.RETRY_BACKOFF_RATELIMIT}. + * @param {module:scheduler~queueAlgorithm} queueAlgorithm Optional. The queuing + * algorithm to apply when determining which events should be sent before the + * given event. Defaults to {@link module:scheduler~MatrixScheduler.QUEUE_MESSAGES}. + */ +function MatrixScheduler(retryAlgorithm, queueAlgorithm) { + this.retryAlgorithm = retryAlgorithm || MatrixScheduler.RETRY_BACKOFF_RATELIMIT; + this.queueAlgorithm = queueAlgorithm || MatrixScheduler.QUEUE_MESSAGES; + this._queues = { + // queueName: [{ + // event: MatrixEvent, // event to send + // defer: Deferred, // defer to resolve/reject at the END of the retries + // attempts: Number // number of times we've called processFn + // }, ...] + }; + this._activeQueues = []; + this._procFn = null; +} + +/** + * Retrieve a queue based on an event. The event provided does not need to be in + * the queue. + * @param {MatrixEvent} event An event to get the queue for. + * @return {?Array} A shallow copy of events in the queue or null. + * Modifying this array will not modify the list itself. Modifying events in + * this array will modify the underlying event in the queue. + * @see MatrixScheduler.removeEventFromQueue To remove an event from the queue. + */ +MatrixScheduler.prototype.getQueueForEvent = function(event) { + const name = this.queueAlgorithm(event); + if (!name || !this._queues[name]) { + return null; + } + return utils.map(this._queues[name], function(obj) { + return obj.event; + }); +}; + +/** + * Remove this event from the queue. The event is equal to another event if they + * have the same ID returned from event.getId(). + * @param {MatrixEvent} event The event to remove. + * @return {boolean} True if this event was removed. + */ +MatrixScheduler.prototype.removeEventFromQueue = function(event) { + const name = this.queueAlgorithm(event); + if (!name || !this._queues[name]) { + return false; + } + let removed = false; + utils.removeElement(this._queues[name], function(element) { + if (element.event.getId() === event.getId()) { + // XXX we should probably reject the promise? + // https://github.com/matrix-org/matrix-js-sdk/issues/496 + removed = true; + return true; + } + }); + return removed; +}; + + +/** + * Set the process function. Required for events in the queue to be processed. + * If set after events have been added to the queue, this will immediately start + * processing them. + * @param {module:scheduler~processFn} fn The function that can process events + * in the queue. + */ +MatrixScheduler.prototype.setProcessFunction = function(fn) { + this._procFn = fn; + _startProcessingQueues(this); +}; + +/** + * Queue an event if it is required and start processing queues. + * @param {MatrixEvent} event The event that may be queued. + * @return {?Promise} A promise if the event was queued, which will be + * resolved or rejected in due time, else null. + */ +MatrixScheduler.prototype.queueEvent = function(event) { + const queueName = this.queueAlgorithm(event); + if (!queueName) { + return null; + } + // add the event to the queue and make a deferred for it. + if (!this._queues[queueName]) { + this._queues[queueName] = []; + } + const defer = Promise.defer(); + this._queues[queueName].push({ + event: event, + defer: defer, + attempts: 0, + }); + debuglog( + "Queue algorithm dumped event %s into queue '%s'", + event.getId(), queueName, + ); + _startProcessingQueues(this); + return defer.promise; +}; + +/** + * Retries events up to 4 times using exponential backoff. This produces wait + * times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the + * failure was due to a rate limited request, the time specified in the error is + * waited before being retried. + * @param {MatrixEvent} event + * @param {Number} attempts + * @param {MatrixError} err + * @return {Number} + * @see module:scheduler~retryAlgorithm + */ +MatrixScheduler.RETRY_BACKOFF_RATELIMIT = function(event, attempts, err) { + if (err.httpStatus === 400 || err.httpStatus === 403 || err.httpStatus === 401) { + // client error; no amount of retrying with save you now. + return -1; + } + // we ship with browser-request which returns { cors: rejected } when trying + // with no connection, so if we match that, give up since they have no conn. + if (err.cors === "rejected") { + return -1; + } + + if (err.name === "M_LIMIT_EXCEEDED") { + const waitTime = err.data.retry_after_ms; + if (waitTime) { + return waitTime; + } + } + if (attempts > 4) { + return -1; // give up + } + return (1000 * Math.pow(2, attempts)); +}; + +/** + * Queues m.room.message events and lets other events continue + * concurrently. + * @param {MatrixEvent} event + * @return {string} + * @see module:scheduler~queueAlgorithm + */ +MatrixScheduler.QUEUE_MESSAGES = function(event) { + // enqueue messages or events that associate with another event (redactions and relations) + if (event.getType() === "m.room.message" || event.hasAssocation()) { + // put these events in the 'message' queue. + return "message"; + } + // allow all other events continue concurrently. + return null; +}; + +function _startProcessingQueues(scheduler) { + if (!scheduler._procFn) { + return; + } + // for each inactive queue with events in them + utils.forEach(utils.filter(utils.keys(scheduler._queues), function(queueName) { + return scheduler._activeQueues.indexOf(queueName) === -1 && + scheduler._queues[queueName].length > 0; + }), function(queueName) { + // mark the queue as active + scheduler._activeQueues.push(queueName); + // begin processing the head of the queue + debuglog("Spinning up queue: '%s'", queueName); + _processQueue(scheduler, queueName); + }); +} + +function _processQueue(scheduler, queueName) { + // get head of queue + const obj = _peekNextEvent(scheduler, queueName); + if (!obj) { + // queue is empty. Mark as inactive and stop recursing. + const index = scheduler._activeQueues.indexOf(queueName); + if (index >= 0) { + scheduler._activeQueues.splice(index, 1); + } + debuglog("Stopping queue '%s' as it is now empty", queueName); + return; + } + debuglog( + "Queue '%s' has %s pending events", + queueName, scheduler._queues[queueName].length, + ); + // fire the process function and if it resolves, resolve the deferred. Else + // invoke the retry algorithm. + + // First wait for a resolved promise, so the resolve handlers for + // the deferred of the previously sent event can run. + // This way enqueued relations/redactions to enqueued events can receive + // the remove id of their target before being sent. + Promise.resolve().then(() => { + return scheduler._procFn(obj.event); + }).then(function(res) { + // remove this from the queue + _removeNextEvent(scheduler, queueName); + debuglog("Queue '%s' sent event %s", queueName, obj.event.getId()); + obj.defer.resolve(res); + // keep processing + _processQueue(scheduler, queueName); + }, function(err) { + obj.attempts += 1; + // ask the retry algorithm when/if we should try again + const waitTimeMs = scheduler.retryAlgorithm(obj.event, obj.attempts, err); + debuglog( + "retry(%s) err=%s event_id=%s waitTime=%s", + obj.attempts, err, obj.event.getId(), waitTimeMs, + ); + if (waitTimeMs === -1) { // give up (you quitter!) + debuglog( + "Queue '%s' giving up on event %s", queueName, obj.event.getId(), + ); + // remove this from the queue + _removeNextEvent(scheduler, queueName); + obj.defer.reject(err); + // process next event + _processQueue(scheduler, queueName); + } else { + setTimeout(function() { + _processQueue(scheduler, queueName); + }, waitTimeMs); + } + }); +} + +function _peekNextEvent(scheduler, queueName) { + const queue = scheduler._queues[queueName]; + if (!utils.isArray(queue)) { + return null; + } + return queue[0]; +} + +function _removeNextEvent(scheduler, queueName) { + const queue = scheduler._queues[queueName]; + if (!utils.isArray(queue)) { + return null; + } + return queue.shift(); +} + +function debuglog() { + if (DEBUG) { + logger.log(...arguments); + } +} + +/** + * The retry algorithm to apply when retrying events. To stop retrying, return + * -1. If this event was part of a queue, it will be removed from + * the queue. + * @callback retryAlgorithm + * @param {MatrixEvent} event The event being retried. + * @param {Number} attempts The number of failed attempts. This will always be + * >= 1. + * @param {MatrixError} err The most recent error message received when trying + * to send this event. + * @return {Number} The number of milliseconds to wait before trying again. If + * this is 0, the request will be immediately retried. If this is + * -1, the event will be marked as + * {@link module:models/event.EventStatus.NOT_SENT} and will not be retried. + */ + +/** + * The queuing algorithm to apply to events. This function must be idempotent as + * it may be called multiple times with the same event. All queues created are + * serviced in a FIFO manner. To send the event ASAP, return null + * which will not put this event in a queue. Events that fail to send that form + * part of a queue will be removed from the queue and the next event in the + * queue will be sent. + * @callback queueAlgorithm + * @param {MatrixEvent} event The event to be sent. + * @return {string} The name of the queue to put the event into. If a queue with + * this name does not exist, it will be created. If this is null, + * the event is not put into a queue and will be sent concurrently. + */ + + /** + * The function to invoke to process (send) events in the queue. + * @callback processFn + * @param {MatrixEvent} event The event to send. + * @return {Promise} Resolved/rejected depending on the outcome of the request. + */ + +/** + * The MatrixScheduler class. + */ +module.exports = MatrixScheduler; diff --git a/matrix-js-sdk/src/store/indexeddb-local-backend.js b/matrix-js-sdk/src/store/indexeddb-local-backend.js new file mode 100644 index 000000000..7d6662794 --- /dev/null +++ b/matrix-js-sdk/src/store/indexeddb-local-backend.js @@ -0,0 +1,577 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ + +import Promise from 'bluebird'; +import SyncAccumulator from "../sync-accumulator"; +import utils from "../utils"; +import * as IndexedDBHelpers from "../indexeddb-helpers"; +import logger from '../../src/logger'; + +const VERSION = 3; + +function createDatabase(db) { + // Make user store, clobber based on user ID. (userId property of User objects) + db.createObjectStore("users", { keyPath: ["userId"] }); + + // Make account data store, clobber based on event type. + // (event.type property of MatrixEvent objects) + db.createObjectStore("accountData", { keyPath: ["type"] }); + + // Make /sync store (sync tokens, room data, etc), always clobber (const key). + db.createObjectStore("sync", { keyPath: ["clobber"] }); +} + +function upgradeSchemaV2(db) { + const oobMembersStore = db.createObjectStore( + "oob_membership_events", { + keyPath: ["room_id", "state_key"], + }); + oobMembersStore.createIndex("room", "room_id"); +} + +function upgradeSchemaV3(db) { + db.createObjectStore("client_options", + { keyPath: ["clobber"]}); +} + + +/** + * Helper method to collect results from a Cursor and promiseify it. + * @param {ObjectStore|Index} store The store to perform openCursor on. + * @param {IDBKeyRange=} keyRange Optional key range to apply on the cursor. + * @param {Function} resultMapper A function which is repeatedly called with a + * Cursor. + * Return the data you want to keep. + * @return {Promise} Resolves to an array of whatever you returned from + * resultMapper. + */ +function selectQuery(store, keyRange, resultMapper) { + const query = store.openCursor(keyRange); + return new Promise((resolve, reject) => { + const results = []; + query.onerror = (event) => { + reject(new Error("Query failed: " + event.target.errorCode)); + }; + // collect results + query.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + resolve(results); + return; // end of results + } + results.push(resultMapper(cursor)); + cursor.continue(); + }; + }); +} + +function txnAsPromise(txn) { + return new Promise((resolve, reject) => { + txn.oncomplete = function(event) { + resolve(event); + }; + txn.onerror = function(event) { + reject(event.target.error); + }; + }); +} + +function reqAsEventPromise(req) { + return new Promise((resolve, reject) => { + req.onsuccess = function(event) { + resolve(event); + }; + req.onerror = function(event) { + reject(event.target.error); + }; + }); +} + +function reqAsPromise(req) { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req); + req.onerror = (err) => reject(err); + }); +} + +function reqAsCursorPromise(req) { + return reqAsEventPromise(req).then((event) => event.target.result); +} + +/** + * Does the actual reading from and writing to the indexeddb + * + * Construct a new Indexed Database store backend. This requires a call to + * connect() before this store can be used. + * @constructor + * @param {Object} indexedDBInterface The Indexed DB interface e.g + * window.indexedDB + * @param {string=} dbName Optional database name. The same name must be used + * to open the same database. + */ +const LocalIndexedDBStoreBackend = function LocalIndexedDBStoreBackend( + indexedDBInterface, dbName, +) { + this.indexedDB = indexedDBInterface; + this._dbName = "matrix-js-sdk:" + (dbName || "default"); + this.db = null; + this._disconnected = true; + this._syncAccumulator = new SyncAccumulator(); + this._isNewlyCreated = false; +}; + +LocalIndexedDBStoreBackend.exists = function(indexedDB, dbName) { + dbName = "matrix-js-sdk:" + (dbName || "default"); + return IndexedDBHelpers.exists(indexedDB, dbName); +}; + +LocalIndexedDBStoreBackend.prototype = { + /** + * Attempt to connect to the database. This can fail if the user does not + * grant permission. + * @return {Promise} Resolves if successfully connected. + */ + connect: function() { + if (!this._disconnected) { + logger.log( + `LocalIndexedDBStoreBackend.connect: already connected or connecting`, + ); + return Promise.resolve(); + } + + this._disconnected = false; + + logger.log( + `LocalIndexedDBStoreBackend.connect: connecting...`, + ); + const req = this.indexedDB.open(this._dbName, VERSION); + req.onupgradeneeded = (ev) => { + const db = ev.target.result; + const oldVersion = ev.oldVersion; + logger.log( + `LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`, + ); + if (oldVersion < 1) { // The database did not previously exist. + this._isNewlyCreated = true; + createDatabase(db); + } + if (oldVersion < 2) { + upgradeSchemaV2(db); + } + if (oldVersion < 3) { + upgradeSchemaV3(db); + } + // Expand as needed. + }; + + req.onblocked = () => { + logger.log( + `can't yet open LocalIndexedDBStoreBackend because it is open elsewhere`, + ); + }; + + logger.log( + `LocalIndexedDBStoreBackend.connect: awaiting connection...`, + ); + return reqAsEventPromise(req).then((ev) => { + logger.log( + `LocalIndexedDBStoreBackend.connect: connected`, + ); + this.db = ev.target.result; + + // add a poorly-named listener for when deleteDatabase is called + // so we can close our db connections. + this.db.onversionchange = () => { + this.db.close(); + }; + + return this._init(); + }); + }, + /** @return {bool} whether or not the database was newly created in this session. */ + isNewlyCreated: function() { + return Promise.resolve(this._isNewlyCreated); + }, + + /** + * Having connected, load initial data from the database and prepare for use + * @return {Promise} Resolves on success + */ + _init: function() { + return Promise.all([ + this._loadAccountData(), + this._loadSyncData(), + ]).then(([accountData, syncData]) => { + logger.log( + `LocalIndexedDBStoreBackend: loaded initial data`, + ); + this._syncAccumulator.accumulate({ + next_batch: syncData.nextBatch, + rooms: syncData.roomsData, + groups: syncData.groupsData, + account_data: { + events: accountData, + }, + }); + }); + }, + + /** + * Returns the out-of-band membership events for this room that + * were previously loaded. + * @param {string} roomId + * @returns {Promise} 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 new Promise((resolve, reject) =>{ + const tx = this.db.transaction(["oob_membership_events"], "readonly"); + const store = tx.objectStore("oob_membership_events"); + const roomIndex = store.index("room"); + const range = IDBKeyRange.only(roomId); + const request = roomIndex.openCursor(range); + + const membershipEvents = []; + // did we encounter the oob_written marker object + // amongst the results? That means OOB member + // loading already happened for this room + // but there were no members to persist as they + // were all known already + let oobWritten = false; + + request.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + // Unknown room + if (!membershipEvents.length && !oobWritten) { + return resolve(null); + } + return resolve(membershipEvents); + } + const record = cursor.value; + if (record.oob_written) { + oobWritten = true; + } else { + membershipEvents.push(record); + } + cursor.continue(); + }; + request.onerror = (err) => { + reject(err); + }; + }).then((events) => { + logger.log(`LL: got ${events && events.length}` + + ` membershipEvents from storage for room ${roomId} ...`); + return events; + }); + }, + + /** + * 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 + */ + setOutOfBandMembers: async function(roomId, membershipEvents) { + logger.log(`LL: backend about to store ${membershipEvents.length}` + + ` members for ${roomId}`); + const tx = this.db.transaction(["oob_membership_events"], "readwrite"); + const store = tx.objectStore("oob_membership_events"); + membershipEvents.forEach((e) => { + store.put(e); + }); + // aside from all the events, we also write a marker object to the store + // to mark the fact that OOB members have been written for this room. + // It's possible that 0 members need to be written as all where previously know + // but we still need to know whether to return null or [] from getOutOfBandMembers + // where null means out of band members haven't been stored yet for this room + const markerObject = { + room_id: roomId, + oob_written: true, + state_key: 0, + }; + store.put(markerObject); + await txnAsPromise(tx); + logger.log(`LL: backend done storing for ${roomId}!`); + }, + + clearOutOfBandMembers: async function(roomId) { + // the approach to delete all members for a room + // is to get the min and max state key from the index + // for that room, and then delete between those + // keys in the store. + // this should be way faster than deleting every member + // individually for a large room. + const readTx = this.db.transaction( + ["oob_membership_events"], + "readonly"); + const store = readTx.objectStore("oob_membership_events"); + const roomIndex = store.index("room"); + const roomRange = IDBKeyRange.only(roomId); + + const minStateKeyProm = reqAsCursorPromise( + roomIndex.openKeyCursor(roomRange, "next"), + ).then((cursor) => cursor && cursor.primaryKey[1]); + const maxStateKeyProm = reqAsCursorPromise( + roomIndex.openKeyCursor(roomRange, "prev"), + ).then((cursor) => cursor && cursor.primaryKey[1]); + const [minStateKey, maxStateKey] = await Promise.all( + [minStateKeyProm, maxStateKeyProm]); + + const writeTx = this.db.transaction( + ["oob_membership_events"], + "readwrite"); + const writeStore = writeTx.objectStore("oob_membership_events"); + const membersKeyRange = IDBKeyRange.bound( + [roomId, minStateKey], + [roomId, maxStateKey], + ); + + logger.log(`LL: Deleting all users + marker in storage for ` + + `room ${roomId}, with key range:`, + [roomId, minStateKey], [roomId, maxStateKey]); + await reqAsPromise(writeStore.delete(membersKeyRange)); + }, + + /** + * Clear the entire database. This should be used when logging out of a client + * to prevent mixing data between accounts. + * @return {Promise} Resolved when the database is cleared. + */ + clearDatabase: function() { + return new Promise((resolve, reject) => { + logger.log(`Removing indexeddb instance: ${this._dbName}`); + const req = this.indexedDB.deleteDatabase(this._dbName); + + req.onblocked = () => { + logger.log( + `can't yet delete indexeddb ${this._dbName}` + + ` because it is open elsewhere`, + ); + }; + + req.onerror = (ev) => { + // in firefox, with indexedDB disabled, this fails with a + // DOMError. We treat this as non-fatal, so that we can still + // use the app. + logger.warn( + `unable to delete js-sdk store indexeddb: ${ev.target.error}`, + ); + resolve(); + }; + + req.onsuccess = () => { + logger.log(`Removed indexeddb instance: ${this._dbName}`); + resolve(); + }; + }); + }, + + /** + * @param {boolean=} copy If false, the data returned is from internal + * buffers and must not be mutated. Otherwise, a copy is made before + * returning such that the data can be safely mutated. Default: true. + * + * @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(copy) { + if (copy === undefined) copy = true; + + const data = this._syncAccumulator.getJSON(); + if (!data.nextBatch) return Promise.resolve(null); + if (copy) { + // We must deep copy the stored data so that the /sync processing code doesn't + // corrupt the internal state of the sync accumulator (it adds non-clonable keys) + return Promise.resolve(utils.deepCopy(data)); + } else { + return Promise.resolve(data); + } + }, + + getNextBatchToken: function() { + return Promise.resolve(this._syncAccumulator.getNextBatchToken()); + }, + + setSyncData: function(syncData) { + return Promise.resolve().then(() => { + this._syncAccumulator.accumulate(syncData); + }); + }, + + syncToDatabase: function(userTuples) { + const syncData = this._syncAccumulator.getJSON(); + + return Promise.all([ + this._persistUserPresenceEvents(userTuples), + this._persistAccountData(syncData.accountData), + this._persistSyncData( + syncData.nextBatch, syncData.roomsData, syncData.groupsData, + ), + ]); + }, + + /** + * Persist rooms /sync data along with the next batch token. + * @param {string} nextBatch The next_batch /sync value. + * @param {Object} roomsData The 'rooms' /sync data from a SyncAccumulator + * @param {Object} groupsData The 'groups' /sync data from a SyncAccumulator + * @return {Promise} Resolves if the data was persisted. + */ + _persistSyncData: function(nextBatch, roomsData, groupsData) { + logger.log("Persisting sync data up to ", nextBatch); + return Promise.try(() => { + const txn = this.db.transaction(["sync"], "readwrite"); + const store = txn.objectStore("sync"); + store.put({ + clobber: "-", // constant key so will always clobber + nextBatch: nextBatch, + roomsData: roomsData, + groupsData: groupsData, + }); // put == UPSERT + return txnAsPromise(txn); + }); + }, + + /** + * Persist a list of account data events. Events with the same 'type' will + * be replaced. + * @param {Object[]} accountData An array of raw user-scoped account data events + * @return {Promise} Resolves if the events were persisted. + */ + _persistAccountData: function(accountData) { + return Promise.try(() => { + const txn = this.db.transaction(["accountData"], "readwrite"); + const store = txn.objectStore("accountData"); + for (let i = 0; i < accountData.length; i++) { + store.put(accountData[i]); // put == UPSERT + } + return txnAsPromise(txn); + }); + }, + + /** + * Persist a list of [user id, presence event] they are for. + * Users with the same 'userId' will be replaced. + * Presence events should be the event in its raw form (not the Event + * object) + * @param {Object[]} tuples An array of [userid, event] tuples + * @return {Promise} Resolves if the users were persisted. + */ + _persistUserPresenceEvents: function(tuples) { + return Promise.try(() => { + const txn = this.db.transaction(["users"], "readwrite"); + const store = txn.objectStore("users"); + for (const tuple of tuples) { + store.put({ + userId: tuple[0], + event: tuple[1], + }); // put == UPSERT + } + return txnAsPromise(txn); + }); + }, + + /** + * Load all user presence events from the database. This is not cached. + * FIXME: It would probably be more sensible to store the events in the + * sync. + * @return {Promise} A list of presence events in their raw form. + */ + getUserPresenceEvents: function() { + return Promise.try(() => { + const txn = this.db.transaction(["users"], "readonly"); + const store = txn.objectStore("users"); + return selectQuery(store, undefined, (cursor) => { + return [cursor.value.userId, cursor.value.event]; + }); + }); + }, + + /** + * Load all the account data events from the database. This is not cached. + * @return {Promise} A list of raw global account events. + */ + _loadAccountData: function() { + logger.log( + `LocalIndexedDBStoreBackend: loading account data...`, + ); + return Promise.try(() => { + const txn = this.db.transaction(["accountData"], "readonly"); + const store = txn.objectStore("accountData"); + return selectQuery(store, undefined, (cursor) => { + return cursor.value; + }).then((result) => { + logger.log( + `LocalIndexedDBStoreBackend: loaded account data`, + ); + return result; + }); + }); + }, + + /** + * Load the sync data from the database. + * @return {Promise} An object with "roomsData" and "nextBatch" keys. + */ + _loadSyncData: function() { + logger.log( + `LocalIndexedDBStoreBackend: loading sync data...`, + ); + return Promise.try(() => { + const txn = this.db.transaction(["sync"], "readonly"); + const store = txn.objectStore("sync"); + return selectQuery(store, undefined, (cursor) => { + return cursor.value; + }).then((results) => { + logger.log( + `LocalIndexedDBStoreBackend: loaded sync data`, + ); + if (results.length > 1) { + logger.warn("loadSyncData: More than 1 sync row found."); + } + return (results.length > 0 ? results[0] : {}); + }); + }); + }, + + getClientOptions: function() { + return Promise.resolve().then(() => { + const txn = this.db.transaction(["client_options"], "readonly"); + const store = txn.objectStore("client_options"); + return selectQuery(store, undefined, (cursor) => { + if (cursor.value && cursor.value && cursor.value.options) { + return cursor.value.options; + } + }).then((results) => results[0]); + }); + }, + + storeClientOptions: async function(options) { + const txn = this.db.transaction(["client_options"], "readwrite"); + const store = txn.objectStore("client_options"); + store.put({ + clobber: "-", // constant key so will always clobber + options: options, + }); // put == UPSERT + await txnAsPromise(txn); + }, +}; + +export default LocalIndexedDBStoreBackend; diff --git a/matrix-js-sdk/src/store/indexeddb-remote-backend.js b/matrix-js-sdk/src/store/indexeddb-remote-backend.js new file mode 100644 index 000000000..896d53b74 --- /dev/null +++ b/matrix-js-sdk/src/store/indexeddb-remote-backend.js @@ -0,0 +1,198 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ + +import Promise from 'bluebird'; +import logger from '../../src/logger'; + +/** + * An IndexedDB store backend where the actual backend sits in a web + * worker. + * + * Construct a new Indexed Database store backend. This requires a call to + * connect() before this store can be used. + * @constructor + * @param {string} workerScript URL to the worker script + * @param {string=} dbName Optional database name. The same name must be used + * to open the same database. + * @param {Object} workerApi The web worker compatible interface object + */ +const RemoteIndexedDBStoreBackend = function RemoteIndexedDBStoreBackend( + workerScript, dbName, workerApi, +) { + this._workerScript = workerScript; + this._dbName = dbName; + this._workerApi = workerApi; + this._worker = null; + this._nextSeq = 0; + // The currently in-flight requests to the actual backend + this._inFlight = { + // seq: promise, + }; + // Once we start connecting, we keep the promise and re-use it + // if we try to connect again + this._startPromise = null; +}; + + +RemoteIndexedDBStoreBackend.prototype = { + /** + * Attempt to connect to the database. This can fail if the user does not + * grant permission. + * @return {Promise} Resolves if successfully connected. + */ + connect: function() { + return this._ensureStarted().then(() => this._doCmd('connect')); + }, + + /** + * Clear the entire database. This should be used when logging out of a client + * to prevent mixing data between accounts. + * @return {Promise} Resolved when the database is cleared. + */ + clearDatabase: function() { + return this._ensureStarted().then(() => this._doCmd('clearDatabase')); + }, + /** @return {Promise} whether or not the database was newly created in this session. */ + isNewlyCreated: function() { + return this._doCmd('isNewlyCreated'); + }, + /** + * @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() { + return this._doCmd('getSavedSync'); + }, + + getNextBatchToken: function() { + return this._doCmd('getNextBatchToken'); + }, + + setSyncData: function(syncData) { + return this._doCmd('setSyncData', [syncData]); + }, + + syncToDatabase: function(users) { + return this._doCmd('syncToDatabase', [users]); + }, + + /** + * 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 + */ + getOutOfBandMembers: function(roomId) { + return this._doCmd('getOutOfBandMembers', [roomId]); + }, + + /** + * 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 + */ + setOutOfBandMembers: function(roomId, membershipEvents) { + return this._doCmd('setOutOfBandMembers', [roomId, membershipEvents]); + }, + + clearOutOfBandMembers: function(roomId) { + return this._doCmd('clearOutOfBandMembers', [roomId]); + }, + + getClientOptions: function() { + return this._doCmd('getClientOptions'); + }, + + storeClientOptions: function(options) { + return this._doCmd('storeClientOptions', [options]); + }, + + /** + * Load all user presence events from the database. This is not cached. + * @return {Promise} A list of presence events in their raw form. + */ + getUserPresenceEvents: function() { + return this._doCmd('getUserPresenceEvents'); + }, + + _ensureStarted: function() { + if (this._startPromise === null) { + this._worker = new this._workerApi(this._workerScript); + this._worker.onmessage = this._onWorkerMessage.bind(this); + + // tell the worker the db name. + this._startPromise = this._doCmd('_setupWorker', [this._dbName]).then(() => { + logger.log("IndexedDB worker is ready"); + }); + } + return this._startPromise; + }, + + _doCmd: function(cmd, args) { + // wrap in a q so if the postMessage throws, + // the promise automatically gets rejected + return Promise.resolve().then(() => { + const seq = this._nextSeq++; + const def = Promise.defer(); + + this._inFlight[seq] = def; + + this._worker.postMessage({ + command: cmd, + seq: seq, + args: args, + }); + + return def.promise; + }); + }, + + _onWorkerMessage: function(ev) { + const msg = ev.data; + + if (msg.command == 'cmd_success' || msg.command == 'cmd_fail') { + if (msg.seq === undefined) { + logger.error("Got reply from worker with no seq"); + return; + } + + const def = this._inFlight[msg.seq]; + if (def === undefined) { + logger.error("Got reply for unknown seq " + msg.seq); + return; + } + delete this._inFlight[msg.seq]; + + if (msg.command == 'cmd_success') { + def.resolve(msg.result); + } else { + const error = new Error(msg.error.message); + error.name = msg.error.name; + def.reject(error); + } + } else { + logger.warn("Unrecognised message from worker: " + msg); + } + }, +}; + +export default RemoteIndexedDBStoreBackend; diff --git a/matrix-js-sdk/src/store/indexeddb-store-worker.js b/matrix-js-sdk/src/store/indexeddb-store-worker.js new file mode 100644 index 000000000..93d374d2f --- /dev/null +++ b/matrix-js-sdk/src/store/indexeddb-store-worker.js @@ -0,0 +1,148 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ + +import Promise from 'bluebird'; +import LocalIndexedDBStoreBackend from "./indexeddb-local-backend.js"; +import logger from '../../src/logger'; + +/** + * This class lives in the webworker and drives a LocalIndexedDBStoreBackend + * controlled by messages from the main process. + * + * It should be instantiated by a web worker script provided by the application + * in a script, for example: + * + * import {IndexedDBStoreWorker} from 'matrix-js-sdk/lib/indexeddb-worker.js'; + * const remoteWorker = new IndexedDBStoreWorker(postMessage); + * onmessage = remoteWorker.onMessage; + * + * Note that it is advisable to import this class by referencing the file directly to + * avoid a dependency on the whole js-sdk. + * + */ +class IndexedDBStoreWorker { + /** + * @param {function} postMessage The web worker postMessage function that + * should be used to communicate back to the main script. + */ + constructor(postMessage) { + this.backend = null; + this.postMessage = postMessage; + + this.onMessage = this.onMessage.bind(this); + } + + /** + * Passes a message event from the main script into the class. This method + * can be directly assigned to the web worker `onmessage` variable. + * + * @param {Object} ev The message event + */ + onMessage(ev) { + const msg = ev.data; + let prom; + + switch (msg.command) { + case '_setupWorker': + this.backend = new LocalIndexedDBStoreBackend( + // this is the 'indexedDB' global (where global != window + // because it's a web worker and there is no window). + indexedDB, msg.args[0], + ); + prom = Promise.resolve(); + break; + case 'connect': + prom = this.backend.connect(); + break; + case 'isNewlyCreated': + prom = this.backend.isNewlyCreated(); + break; + case 'clearDatabase': + prom = this.backend.clearDatabase().then((result) => { + // This returns special classes which can't be cloned + // across to the main script, so don't try. + return {}; + }); + break; + case 'getSavedSync': + prom = this.backend.getSavedSync(false); + break; + case 'setSyncData': + prom = this.backend.setSyncData(...msg.args); + break; + case 'syncToDatabase': + prom = this.backend.syncToDatabase(...msg.args).then(() => { + // This also returns IndexedDB events which are not cloneable + return {}; + }); + break; + case 'getUserPresenceEvents': + prom = this.backend.getUserPresenceEvents(); + break; + case 'getNextBatchToken': + prom = this.backend.getNextBatchToken(); + break; + case 'getOutOfBandMembers': + prom = this.backend.getOutOfBandMembers(msg.args[0]); + break; + case 'clearOutOfBandMembers': + prom = this.backend.clearOutOfBandMembers(msg.args[0]); + break; + case 'setOutOfBandMembers': + prom = this.backend.setOutOfBandMembers(msg.args[0], msg.args[1]); + break; + case 'getClientOptions': + prom = this.backend.getClientOptions(); + break; + case 'storeClientOptions': + prom = this.backend.storeClientOptions(msg.args[0]); + break; + } + + if (prom === undefined) { + this.postMessage({ + command: 'cmd_fail', + seq: msg.seq, + // Can't be an Error because they're not structured cloneable + error: "Unrecognised command", + }); + return; + } + + prom.done((ret) => { + this.postMessage.call(null, { + command: 'cmd_success', + seq: msg.seq, + result: ret, + }); + }, (err) => { + logger.error("Error running command: "+msg.command); + logger.error(err); + this.postMessage.call(null, { + command: 'cmd_fail', + seq: msg.seq, + // Just send a string because Error objects aren't cloneable + error: { + message: err.message, + name: err.name, + }, + }); + }); + } +} + +module.exports = IndexedDBStoreWorker; diff --git a/matrix-js-sdk/src/store/indexeddb.js b/matrix-js-sdk/src/store/indexeddb.js new file mode 100644 index 000000000..dd8978b76 --- /dev/null +++ b/matrix-js-sdk/src/store/indexeddb.js @@ -0,0 +1,322 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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 Promise from 'bluebird'; +import {MemoryStore} from "./memory"; +import 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 '../../src/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 = { localStorage: window.localStorage };
    + * let store = new IndexedDBStore();
    + * 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. + */ +const IndexedDBStore = 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"); + +module.exports.IndexedDBStore = IndexedDBStore; + +/** + * 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/matrix-js-sdk/src/store/memory.js b/matrix-js-sdk/src/store/memory.js new file mode 100644 index 000000000..69975bd97 --- /dev/null +++ b/matrix-js-sdk/src/store/memory.js @@ -0,0 +1,428 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ +"use strict"; +/** + * This is an internal module. See {@link MemoryStore} for the public class. + * @module store/memory + */ +const utils = require("../utils"); +const User = require("../models/user"); +import Promise from 'bluebird'; + +/** + * Construct a new in-memory data store for the Matrix Client. + * @constructor + * @param {Object=} opts Config options + * @param {LocalStorage} opts.localStorage The local storage instance to persist + * some forms of data such as tokens. Rooms will NOT be stored. + */ +module.exports.MemoryStore = 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 + // } + }; + /** + * Map event type → content + * @type Map + */ + this.accountData = new Map(); + this.localStorage = opts.localStorage; + this._oobMembers = { + // roomId: [member events] + }; + this._clientOptions = {}; +}; + +module.exports.MemoryStore.prototype = { + + /** + * Retrieve the token to stream from. + * @return {string} The token or null. + */ + getSyncToken: function() { + return this.syncToken; + }, + + /** @return {Promise} whether or not the database was newly created in this session. */ + isNewlyCreated: function() { + return Promise.resolve(true); + }, + + /** + * Set the token to stream from. + * @param {string} token The token to stream from. + */ + setSyncToken: function(token) { + this.syncToken = token; + }, + + /** + * Store the given room. + * @param {Group} group The group to be stored + */ + storeGroup: function(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) { + return this.groups[groupId] || null; + }, + + /** + * Retrieve all known groups. + * @return {Group[]} A list of groups, which may be empty. + */ + getGroups: function() { + return utils.values(this.groups); + }, + + /** + * Store the given room. + * @param {Room} room The room to be stored. All properties must be stored. + */ + storeRoom: function(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)); + // add existing members + const self = this; + room.currentState.getMembers().forEach(function(m) { + self._onRoomMember(null, room.currentState, m); + }); + }, + + /** + * Called when a room member in a room being tracked by this store has been + * updated. + * @param {MatrixEvent} event + * @param {RoomState} state + * @param {RoomMember} member + */ + _onRoomMember: function(event, state, member) { + 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 (!) + return; + } + + const user = this.users[member.userId] || new User(member.userId); + if (member.name) { + user.setDisplayName(member.name); + if (member.events.member) { + user.setRawDisplayName( + member.events.member.getDirectionalContent().displayname, + ); + } + } + if (member.events.member && member.events.member.getContent().avatar_url) { + 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) { + return this.rooms[roomId] || null; + }, + + /** + * Retrieve all known rooms. + * @return {Room[]} A list of rooms, which may be empty. + */ + getRooms: function() { + return utils.values(this.rooms); + }, + + /** + * Permanently delete a room. + * @param {string} roomId + */ + removeRoom: function(roomId) { + if (this.rooms[roomId]) { + 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() { + return utils.map(utils.values(this.rooms), function(room) { + return room.summary; + }); + }, + + /** + * Store a User. + * @param {User} user The user to store. + */ + storeUser: function(user) { + 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) { + return this.users[userId] || null; + }, + + /** + * Retrieve all known users. + * @return {User[]} A list of users, which may be empty. + */ + getUsers: function() { + return utils.values(this.users); + }, + + /** + * Retrieve scrollback for this room. + * @param {Room} room The matrix room + * @param {integer} limit The max number of old events to retrieve. + * @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) { + return []; + }, + + /** + * Store events for a room. The events have already been added to the timeline + * @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: function(room, events, token, toStart) { + // no-op because they've already been added to the room instance. + }, + + /** + * Store a filter. + * @param {Filter} filter + */ + storeFilter: function(filter) { + if (!filter) { + return; + } + if (!this.filters[filter.userId]) { + this.filters[filter.userId] = {}; + } + this.filters[filter.userId][filter.filterId] = filter; + }, + + /** + * Retrieve a filter. + * @param {string} userId + * @param {string} filterId + * @return {?Filter} A filter or null. + */ + getFilter: function(userId, filterId) { + 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) { + if (!this.localStorage) { + return null; + } + try { + return this.localStorage.getItem("mxjssdk_memory_filter_" + filterName); + } catch (e) {} + return null; + }, + + /** + * Set a filter name to ID mapping. + * @param {string} filterName + * @param {string} filterId + */ + setFilterIdByName: function(filterName, filterId) { + if (!this.localStorage) { + return; + } + try { + this.localStorage.setItem("mxjssdk_memory_filter_" + filterName, filterId); + } catch (e) {} + }, + + /** + * Store user-scoped account data events. + * N.B. that account data only allows a single event per type, so multiple + * 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.set(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) { + return this.accountData.get(eventType); + }, + + /** + * setSyncData does nothing as there is no backing data store. + * + * @param {Object} syncData The sync data + * @return {Promise} An immediately resolved promise. + */ + setSyncData: function(syncData) { + 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() { + 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) {}, + + /** + * Startup does nothing as this store doesn't require starting up. + * @return {Promise} An immediately resolved promise. + */ + startup: function() { + 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() { + return Promise.resolve(null); + }, + + /** + * @return {Promise} If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + getSavedSyncToken: function() { + return Promise.resolve(null); + }, + + /** + * Delete all data from this store. + * @return {Promise} An immediately resolved promise. + */ + deleteAllData: function() { + this.rooms = { + // roomId: Room + }; + this.users = { + // userId: User + }; + this.syncToken = null; + this.filters = { + // userId: { + // filterId: Filter + // } + }; + this.accountData = new Map();// type : content + return Promise.resolve(); + }, + + /** + * 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 + */ + getOutOfBandMembers: function(roomId) { + return Promise.resolve(this._oobMembers[roomId] || null); + }, + + /** + * 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 + */ + setOutOfBandMembers: function(roomId, membershipEvents) { + this._oobMembers[roomId] = membershipEvents; + return Promise.resolve(); + }, + + clearOutOfBandMembers: function() { + this._oobMembers = {}; + return Promise.resolve(); + }, + + getClientOptions: function() { + return Promise.resolve(this._clientOptions); + }, + + storeClientOptions: function(options) { + this._clientOptions = Object.assign({}, options); + return Promise.resolve(); + }, +}; diff --git a/matrix-js-sdk/src/store/session/webstorage.js b/matrix-js-sdk/src/store/session/webstorage.js new file mode 100644 index 000000000..e379b2fae --- /dev/null +++ b/matrix-js-sdk/src/store/session/webstorage.js @@ -0,0 +1,266 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd +Copyright 2018 New Vector 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. +*/ +"use strict"; + +/** + * @module store/session/webstorage + */ + +const utils = require("../../utils"); +import logger from '../../logger'; + +const DEBUG = false; // set true to enable console logging. +const E2E_PREFIX = "session.e2e."; + +/** + * Construct a web storage session store, capable of storing account keys, + * session keys and access tokens. + * @constructor + * @param {WebStorage} webStore A web storage implementation, e.g. + * 'window.localStorage' or 'window.sessionStorage' or a custom implementation. + * @throws if the supplied 'store' does not meet the Storage interface of the + * WebStorage API. + */ +function WebStorageSessionStore(webStore) { + this.store = webStore; + if (!utils.isFunction(webStore.getItem) || + !utils.isFunction(webStore.setItem) || + !utils.isFunction(webStore.removeItem) || + !utils.isFunction(webStore.key) || + typeof(webStore.length) !== 'number' + ) { + throw new Error( + "Supplied webStore does not meet the WebStorage API interface", + ); + } +} + +WebStorageSessionStore.prototype = { + /** + * Remove the stored end to end account for the logged-in user. + */ + removeEndToEndAccount: function() { + this.store.removeItem(KEY_END_TO_END_ACCOUNT); + }, + + /** + * Load the end to end account for the logged-in user. + * Note that the end-to-end account is now stored in the + * crypto store rather than here: this remains here so + * old sessions can be migrated out of the session store. + * @return {?string} Base64 encoded account. + */ + getEndToEndAccount: function() { + return this.store.getItem(KEY_END_TO_END_ACCOUNT); + }, + + /** + * Retrieves the known devices for all users. + * @return {object} A map from user ID to map of device ID to keys for the device. + */ + getAllEndToEndDevices: function() { + const prefix = keyEndToEndDevicesForUser(''); + const devices = {}; + for (let i = 0; i < this.store.length; ++i) { + const key = this.store.key(i); + const userId = key.substr(prefix.length); + if (key.startsWith(prefix)) devices[userId] = getJsonItem(this.store, key); + } + return devices; + }, + + getEndToEndDeviceTrackingStatus: function() { + return getJsonItem(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS); + }, + + /** + * Get the sync token corresponding to the device list. + * + * @return {String?} token + */ + getEndToEndDeviceSyncToken: function() { + return getJsonItem(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN); + }, + + /** + * Removes all end to end device data from the store + */ + removeEndToEndDeviceData: function() { + removeByPrefix(this.store, keyEndToEndDevicesForUser('')); + removeByPrefix(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS); + removeByPrefix(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN); + }, + + /** + * Retrieve the end-to-end sessions between the logged-in user and another + * device. + * @param {string} deviceKey The public key of the other device. + * @return {object} A map from sessionId to Base64 end-to-end session. + */ + getEndToEndSessions: function(deviceKey) { + return getJsonItem(this.store, keyEndToEndSessions(deviceKey)); + }, + + /** + * Retrieve all end-to-end sessions between the logged-in user and other + * devices. + * @return {object} A map of {deviceKey -> {sessionId -> session pickle}} + */ + getAllEndToEndSessions: function() { + const deviceKeys = getKeysWithPrefix(this.store, keyEndToEndSessions('')); + const results = {}; + for (const k of deviceKeys) { + const unprefixedKey = k.substr(keyEndToEndSessions('').length); + results[unprefixedKey] = getJsonItem(this.store, k); + } + return results; + }, + + /** + * Remove all end-to-end sessions from the store + * This is used after migrating sessions awat from the sessions store. + */ + removeAllEndToEndSessions: function() { + removeByPrefix(this.store, keyEndToEndSessions('')); + }, + + /** + * Retrieve a list of all known inbound group sessions + * + * @return {{senderKey: string, sessionId: string}} + */ + getAllEndToEndInboundGroupSessionKeys: function() { + const prefix = E2E_PREFIX + 'inboundgroupsessions/'; + const result = []; + for (let i = 0; i < this.store.length; i++) { + const key = this.store.key(i); + if (!key.startsWith(prefix)) { + continue; + } + // we can't use split, as the components we are trying to split out + // might themselves contain '/' characters. We rely on the + // senderKey being a (32-byte) curve25519 key, base64-encoded + // (hence 43 characters long). + + result.push({ + senderKey: key.substr(prefix.length, 43), + sessionId: key.substr(prefix.length + 44), + }); + } + return result; + }, + + getEndToEndInboundGroupSession: function(senderKey, sessionId) { + const key = keyEndToEndInboundGroupSession(senderKey, sessionId); + return this.store.getItem(key); + }, + + removeAllEndToEndInboundGroupSessions: function() { + removeByPrefix(this.store, E2E_PREFIX + 'inboundgroupsessions/'); + }, + + /** + * Get the end-to-end state for all rooms + * @return {object} roomId -> object with the end-to-end info for the room. + */ + getAllEndToEndRooms: function() { + const roomKeys = getKeysWithPrefix(this.store, keyEndToEndRoom('')); + const results = {}; + for (const k of roomKeys) { + const unprefixedKey = k.substr(keyEndToEndRoom('').length); + results[unprefixedKey] = getJsonItem(this.store, k); + } + return results; + }, + + removeAllEndToEndRooms: function() { + removeByPrefix(this.store, keyEndToEndRoom('')); + }, + + setLocalTrustedBackupPubKey: function(pubkey) { + this.store.setItem(KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY, pubkey); + }, + + // XXX: This store is deprecated really, but added this as a temporary + // thing until cross-signing lands. + getLocalTrustedBackupPubKey: function() { + return this.store.getItem(KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY); + }, +}; + +const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; +const KEY_END_TO_END_DEVICE_SYNC_TOKEN = E2E_PREFIX + "device_sync_token"; +const KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS = E2E_PREFIX + "device_tracking"; +const KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY = E2E_PREFIX + "trusted_backup_pubkey"; + +function keyEndToEndDevicesForUser(userId) { + return E2E_PREFIX + "devices/" + userId; +} + +function keyEndToEndSessions(deviceKey) { + return E2E_PREFIX + "sessions/" + deviceKey; +} + +function keyEndToEndInboundGroupSession(senderKey, sessionId) { + return E2E_PREFIX + "inboundgroupsessions/" + senderKey + "/" + sessionId; +} + +function keyEndToEndRoom(roomId) { + return E2E_PREFIX + "rooms/" + roomId; +} + +function getJsonItem(store, key) { + try { + // if the key is absent, store.getItem() returns null, and + // JSON.parse(null) === null, so this returns null. + return JSON.parse(store.getItem(key)); + } catch (e) { + debuglog("Failed to get key %s: %s", key, e); + debuglog(e.stack); + } + return null; +} + +function getKeysWithPrefix(store, prefix) { + const results = []; + for (let i = 0; i < store.length; ++i) { + const key = store.key(i); + if (key.startsWith(prefix)) results.push(key); + } + return results; +} + +function removeByPrefix(store, prefix) { + const toRemove = []; + for (let i = 0; i < store.length; ++i) { + const key = store.key(i); + if (key.startsWith(prefix)) toRemove.push(key); + } + for (const key of toRemove) { + store.removeItem(key); + } +} + +function debuglog() { + if (DEBUG) { + logger.log(...arguments); + } +} + +/** */ +module.exports = WebStorageSessionStore; diff --git a/matrix-js-sdk/src/store/stub.js b/matrix-js-sdk/src/store/stub.js new file mode 100644 index 000000000..f09dad6d7 --- /dev/null +++ b/matrix-js-sdk/src/store/stub.js @@ -0,0 +1,295 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ +"use strict"; +import Promise from 'bluebird'; +/** + * This is an internal module. + * @module store/stub + */ + +/** + * Construct a stub store. This does no-ops on most store methods. + * @constructor + */ +function StubStore() { + this.fromToken = null; +} + +StubStore.prototype = { + + /** @return {Promise} whether or not the database was newly created in this session. */ + isNewlyCreated: function() { + return Promise.resolve(true); + }, + + /** + * Get the sync token. + * @return {string} + */ + getSyncToken: function() { + return this.fromToken; + }, + + /** + * Set the sync token. + * @param {string} token + */ + setSyncToken: function(token) { + this.fromToken = token; + }, + + /** + * No-op. + * @param {Group} group + */ + storeGroup: function(group) { + }, + + /** + * No-op. + * @param {string} groupId + * @return {null} + */ + getGroup: function(groupId) { + return null; + }, + + /** + * No-op. + * @return {Array} An empty array. + */ + getGroups: function() { + return []; + }, + + /** + * No-op. + * @param {Room} room + */ + storeRoom: function(room) { + }, + + /** + * No-op. + * @param {string} roomId + * @return {null} + */ + getRoom: function(roomId) { + return null; + }, + + /** + * No-op. + * @return {Array} An empty array. + */ + getRooms: function() { + return []; + }, + + /** + * Permanently delete a room. + * @param {string} roomId + */ + removeRoom: function(roomId) { + return; + }, + + /** + * No-op. + * @return {Array} An empty array. + */ + getRoomSummaries: function() { + return []; + }, + + /** + * No-op. + * @param {User} user + */ + storeUser: function(user) { + }, + + /** + * No-op. + * @param {string} userId + * @return {null} + */ + getUser: function(userId) { + return null; + }, + + /** + * No-op. + * @return {User[]} + */ + getUsers: function() { + return []; + }, + + /** + * No-op. + * @param {Room} room + * @param {integer} limit + * @return {Array} + */ + scrollback: function(room, limit) { + return []; + }, + + /** + * 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: function(room, events, token, toStart) { + }, + + /** + * Store a filter. + * @param {Filter} filter + */ + storeFilter: function(filter) { + }, + + /** + * Retrieve a filter. + * @param {string} userId + * @param {string} filterId + * @return {?Filter} A filter or null. + */ + getFilter: function(userId, filterId) { + 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) { + return null; + }, + + /** + * Set a filter name to ID mapping. + * @param {string} filterName + * @param {string} filterId + */ + setFilterIdByName: function(filterName, filterId) { + + }, + + /** + * Store user-scoped account data events + * @param {Array} events The events to store. + */ + storeAccountDataEvents: function(events) { + + }, + + /** + * Get account data event by event type + * @param {string} eventType The event type being queried + */ + getAccountData: function(eventType) { + + }, + + /** + * setSyncData does nothing as there is no backing data store. + * + * @param {Object} syncData The sync data + * @return {Promise} An immediately resolved promise. + */ + setSyncData: function(syncData) { + 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() { + return false; + }, + + /** + * Save does nothing as there is no backing data store. + */ + save: function() {}, + + /** + * Startup does nothing. + * @return {Promise} An immediately resolved promise. + */ + startup: function() { + 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() { + return Promise.resolve(null); + }, + + /** + * @return {Promise} If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + getSavedSyncToken: function() { + 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() { + return Promise.resolve(); + }, + + getOutOfBandMembers: function() { + return Promise.resolve(null); + }, + + setOutOfBandMembers: function() { + return Promise.resolve(); + }, + + clearOutOfBandMembers: function() { + return Promise.resolve(); + }, + + getClientOptions: function() { + return Promise.resolve(); + }, + + storeClientOptions: function() { + return Promise.resolve(); + }, +}; + +/** Stub Store class. */ +module.exports = StubStore; diff --git a/matrix-js-sdk/src/sync-accumulator.js b/matrix-js-sdk/src/sync-accumulator.js new file mode 100644 index 000000000..a87cbe854 --- /dev/null +++ b/matrix-js-sdk/src/sync-accumulator.js @@ -0,0 +1,560 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ + +/** + * This is an internal module. See {@link SyncAccumulator} for the public class. + * @module sync-accumulator + */ + +import utils from "./utils"; +import logger from '../src/logger'; + + +/** + * The purpose of this class is to accumulate /sync responses such that a + * complete "initial" JSON response can be returned which accurately represents + * the sum total of the /sync responses accumulated to date. It only handles + * room data: that is, everything under the "rooms" top-level key. + * + * This class is used when persisting room data so a complete /sync response can + * be loaded from disk and incremental syncs can be performed on the server, + * rather than asking the server to do an initial sync on startup. + */ +class SyncAccumulator { + /** + * @param {Object} opts + * @param {Number=} opts.maxTimelineEntries The ideal maximum number of + * timeline entries to keep in the sync response. This is best-effort, as + * clients do not always have a back-pagination token for each event, so + * it's possible there may be slightly *less* than this value. There will + * 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; + /** + * Map event type → event + * @type Map + */ + this.accountData = new Map(); + 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: {}, + }; + } + + accumulate(syncResponse) { + this._accumulateRooms(syncResponse); + this._accumulateGroups(syncResponse); + this._accumulateAccountData(syncResponse); + this.nextBatch = syncResponse.next_batch; + } + + _accumulateAccountData(syncResponse) { + if (!syncResponse.account_data || !syncResponse.account_data.events) { + return; + } + // Clobbers based on event type. + syncResponse.account_data.events.forEach((e) => { + this.accountData.set(e.type, e); + }); + } + + /** + * Accumulate incremental /sync room data. + * @param {Object} syncResponse the complete /sync JSON + */ + _accumulateRooms(syncResponse) { + if (!syncResponse.rooms) { + return; + } + if (syncResponse.rooms.invite) { + Object.keys(syncResponse.rooms.invite).forEach((roomId) => { + this._accumulateRoom( + roomId, "invite", syncResponse.rooms.invite[roomId], + ); + }); + } + if (syncResponse.rooms.join) { + Object.keys(syncResponse.rooms.join).forEach((roomId) => { + this._accumulateRoom( + roomId, "join", syncResponse.rooms.join[roomId], + ); + }); + } + if (syncResponse.rooms.leave) { + Object.keys(syncResponse.rooms.leave).forEach((roomId) => { + this._accumulateRoom( + roomId, "leave", syncResponse.rooms.leave[roomId], + ); + }); + } + } + + _accumulateRoom(roomId, category, data) { + // Valid /sync state transitions + // +--------+ <======+ 1: Accept an invite + // +== | INVITE | | (5) 2: Leave a room + // | +--------+ =====+ | 3: Join a public room previously + // |(1) (4) | | left (handle as if new room) + // V (2) V | 4: Reject an invite + // +------+ ========> +--------+ 5: Invite to a room previously + // | JOIN | (3) | LEAVE* | left (handle as if new room) + // +------+ <======== +--------+ + // + // * equivalent to "no state" + switch (category) { + case "invite": // (5) + this._accumulateInviteState(roomId, data); + break; + case "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 + // invite state + delete this.inviteRooms[roomId]; + } + // (3) + this._accumulateJoinState(roomId, data); + break; + case "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) { + if (!data.invite_state || !data.invite_state.events) { // no new data + return; + } + if (!this.inviteRooms[roomId]) { + this.inviteRooms[roomId] = { + invite_state: data.invite_state, + }; + return; + } + // accumulate extra keys for invite->invite transitions + // clobber based on event type / state key + // We expect invite_state to be small, so just loop over the events + const currentData = this.inviteRooms[roomId]; + data.invite_state.events.forEach((e) => { + let hasAdded = false; + for (let i = 0; i < currentData.invite_state.events.length; i++) { + const current = currentData.invite_state.events[i]; + if (current.type === e.type && current.state_key == e.state_key) { + currentData.invite_state.events[i] = e; // update + hasAdded = true; + } + } + if (!hasAdded) { + currentData.invite_state.events.push(e); + } + }); + } + + // Accumulate timeline and state events in a room. + _accumulateJoinState(roomId, data) { + // 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 + // maps all the time, just keep private maps which contain + // the actual current accumulated sync state, and array-ify it when + // getJSON() is called. + + // State resolution: + // The 'state' key is the delta from the previous sync (or start of time + // if no token was supplied), to the START of the timeline. To obtain + // the current state, we need to "roll forward" state by reading the + // timeline. We want to store the current state so we can drop events + // out the end of the timeline based on opts.maxTimelineEntries. + // + // 'state' 'timeline' current state + // |-------x<======================>x + // T I M E + // + // When getJSON() is called, we 'roll back' the current state by the + // number of entries in the timeline to work out what 'state' should be. + + // Back-pagination: + // On an initial /sync, the server provides a back-pagination token for + // the start of the timeline. When /sync deltas come down, they also + // include back-pagination tokens for the start of the timeline. This + // means not all events in the timeline have back-pagination tokens, as + // it is only the ones at the START of the timeline which have them. + // In order for us to have a valid timeline (and back-pagination token + // to match), we need to make sure that when we remove old timeline + // events, that we roll forward to an event which has a back-pagination + // token. This means we can't keep a strict sliding-window based on + // opts.maxTimelineEntries, and we may have a few less. We should never + // have more though, provided that the /sync limit is less than or equal + // to opts.maxTimelineEntries. + + if (!this.joinRooms[roomId]) { + // Create truly empty objects so event types of 'hasOwnProperty' and co + // don't cause this code to break. + this.joinRooms[roomId] = { + _currentState: Object.create(null), + _timeline: [], + _accountData: Object.create(null), + _unreadNotifications: {}, + _summary: {}, + _readReceipts: {}, + }; + } + const currentData = this.joinRooms[roomId]; + + if (data.account_data && data.account_data.events) { + // clobber based on type + data.account_data.events.forEach((e) => { + currentData._accountData[e.type] = e; + }); + } + + // these probably clobber, spec is unclear. + if (data.unread_notifications) { + currentData._unreadNotifications = data.unread_notifications; + } + if (data.summary) { + const HEROES_KEY = "m.heroes"; + const INVITED_COUNT_KEY = "m.invited_member_count"; + const JOINED_COUNT_KEY = "m.joined_member_count"; + + const acc = currentData._summary; + const sum = data.summary; + acc[HEROES_KEY] = sum[HEROES_KEY] || acc[HEROES_KEY]; + acc[JOINED_COUNT_KEY] = sum[JOINED_COUNT_KEY] || acc[JOINED_COUNT_KEY]; + acc[INVITED_COUNT_KEY] = sum[INVITED_COUNT_KEY] || acc[INVITED_COUNT_KEY]; + } + + if (data.ephemeral && data.ephemeral.events) { + data.ephemeral.events.forEach((e) => { + // We purposefully do not persist m.typing events. + // Technically you could refresh a browser before the timer on a + // typing event is up, so it'll look like you aren't typing when + // you really still are. However, the alternative is worse. If + // we do persist typing events, it will look like people are + // typing forever until someone really does start typing (which + // will prompt Synapse to send down an actual m.typing event to + // clobber the one we persisted). + if (e.type !== "m.receipt" || !e.content) { + // This means we'll drop unknown ephemeral events but that + // seems okay. + return; + } + // Handle m.receipt events. They clobber based on: + // (user_id, receipt_type) + // but they are keyed in the event as: + // content:{ $event_id: { $receipt_type: { $user_id: {json} }}} + // so store them in the former so we can accumulate receipt deltas + // quickly and efficiently (we expect a lot of them). Fold the + // receipt type into the key name since we only have 1 at the + // moment (m.read) and nested JSON objects are slower and more + // of a hassle to work with. We'll inflate this back out when + // getJSON() is called. + Object.keys(e.content).forEach((eventId) => { + if (!e.content[eventId]["m.read"]) { + return; + } + Object.keys(e.content[eventId]["m.read"]).forEach((userId) => { + // clobber on user ID + currentData._readReceipts[userId] = { + data: e.content[eventId]["m.read"][userId], + eventId: eventId, + }; + }); + }); + }); + } + + // if we got a limited sync, we need to remove all timeline entries or else + // we will have gaps in the timeline. + if (data.timeline && data.timeline.limited) { + currentData._timeline = []; + } + + // Work out the current state. The deltas need to be applied in the order: + // - existing state which didn't come down /sync. + // - State events under the 'state' key. + // - State events in the 'timeline'. + if (data.state && data.state.events) { + data.state.events.forEach((e) => { + setState(currentData._currentState, e); + }); + } + if (data.timeline && data.timeline.events) { + data.timeline.events.forEach((e, index) => { + // this nops if 'e' isn't a state event + setState(currentData._currentState, e); + // append the event to the timeline. The back-pagination token + // corresponds to the first event in the timeline + currentData._timeline.push({ + event: e, + token: index === 0 ? data.timeline.prev_batch : null, + }); + }); + } + + // attempt to prune the timeline by jumping between events which have + // pagination tokens. + if (currentData._timeline.length > this.opts.maxTimelineEntries) { + const startIndex = ( + currentData._timeline.length - this.opts.maxTimelineEntries + ); + for (let i = startIndex; i < currentData._timeline.length; i++) { + if (currentData._timeline[i].token) { + // keep all events after this, including this one + currentData._timeline = currentData._timeline.slice( + i, currentData._timeline.length, + ); + break; + } + } + } + } + + /** + * Accumulate incremental /sync group data. + * @param {Object} syncResponse the complete /sync JSON + */ + _accumulateGroups(syncResponse) { + if (!syncResponse.groups) { + return; + } + if (syncResponse.groups.invite) { + Object.keys(syncResponse.groups.invite).forEach((groupId) => { + this._accumulateGroup( + groupId, "invite", syncResponse.groups.invite[groupId], + ); + }); + } + if (syncResponse.groups.join) { + Object.keys(syncResponse.groups.join).forEach((groupId) => { + this._accumulateGroup( + groupId, "join", syncResponse.groups.join[groupId], + ); + }); + } + if (syncResponse.groups.leave) { + Object.keys(syncResponse.groups.leave).forEach((groupId) => { + this._accumulateGroup( + groupId, "leave", syncResponse.groups.leave[groupId], + ); + }); + } + } + + _accumulateGroup(groupId, category, data) { + for (const cat of ['invite', 'join', 'leave']) { + delete this.groups[cat][groupId]; + } + this.groups[category][groupId] = data; + } + + /** + * Return everything under the 'rooms' key from a /sync response which + * represents all room data that should be stored. This should be paired + * with the sync token which represents the most recent /sync response + * provided to accumulate(). + * @return {Object} An object with a "nextBatch", "roomsData" and "accountData" + * keys. + * The "nextBatch" key is a string which represents at what point in the + * /sync stream the accumulator reached. This token should be used when + * restarting a /sync stream at startup. Failure to do so can lead to missing + * events. The "roomsData" key is an Object which represents the entire + * /sync response from the 'rooms' key onwards. The "accountData" key is + * a list of raw events which represent global account data. + */ + getJSON() { + const data = { + join: {}, + invite: {}, + // always empty. This is set by /sync when a room was previously + // in 'invite' or 'join'. On fresh startup, the client won't know + // about any previous room being in 'invite' or 'join' so we can + // just omit mentioning it at all, even if it has previously come + // down /sync. + // The notable exception is when a client is kicked or banned: + // we may want to hold onto that room so the client can clearly see + // why their room has disappeared. We don't persist it though because + // it is unclear *when* we can safely remove the room from the DB. + // Instead, we assume that if you're loading from the DB, you've + // refreshed the page, which means you've seen the kick/ban already. + leave: {}, + }; + Object.keys(this.inviteRooms).forEach((roomId) => { + data.invite[roomId] = this.inviteRooms[roomId]; + }); + Object.keys(this.joinRooms).forEach((roomId) => { + const roomData = this.joinRooms[roomId]; + const roomJson = { + ephemeral: { events: [] }, + account_data: { events: [] }, + state: { events: [] }, + timeline: { + events: [], + prev_batch: null, + }, + unread_notifications: roomData._unreadNotifications, + summary: roomData._summary, + }; + // Add account data + Object.keys(roomData._accountData).forEach((evType) => { + roomJson.account_data.events.push(roomData._accountData[evType]); + }); + + // Add receipt data + const receiptEvent = { + type: "m.receipt", + room_id: roomId, + content: { + // $event_id: { "m.read": { $user_id: $json } } + }, + }; + Object.keys(roomData._readReceipts).forEach((userId) => { + const receiptData = roomData._readReceipts[userId]; + if (!receiptEvent.content[receiptData.eventId]) { + receiptEvent.content[receiptData.eventId] = { + "m.read": {}, + }; + } + receiptEvent.content[receiptData.eventId]["m.read"][userId] = ( + receiptData.data + ); + }); + // add only if we have some receipt data + if (Object.keys(receiptEvent.content).length > 0) { + roomJson.ephemeral.events.push(receiptEvent); + } + + // Add timeline data + roomData._timeline.forEach((msgData) => { + if (!roomJson.timeline.prev_batch) { + // the first event we add to the timeline MUST match up to + // the prev_batch token. + if (!msgData.token) { + return; // this shouldn't happen as we prune constantly. + } + roomJson.timeline.prev_batch = msgData.token; + } + roomJson.timeline.events.push(msgData.event); + }); + + // Add state data: roll back current state to the start of timeline, + // by "reverse clobbering" from the end of the timeline to the start. + // Convert maps back into arrays. + const rollBackState = Object.create(null); + for (let i = roomJson.timeline.events.length - 1; i >=0; i--) { + const timelineEvent = roomJson.timeline.events[i]; + if (timelineEvent.state_key === null || + timelineEvent.state_key === undefined) { + continue; // not a state event + } + // since we're going back in time, we need to use the previous + // state value else we'll break causality. We don't have the + // complete previous state event, so we need to create one. + const prevStateEvent = utils.deepCopy(timelineEvent); + if (prevStateEvent.unsigned) { + if (prevStateEvent.unsigned.prev_content) { + prevStateEvent.content = prevStateEvent.unsigned.prev_content; + } + if (prevStateEvent.unsigned.prev_sender) { + prevStateEvent.sender = prevStateEvent.unsigned.prev_sender; + } + } + setState(rollBackState, prevStateEvent); + } + Object.keys(roomData._currentState).forEach((evType) => { + Object.keys(roomData._currentState[evType]).forEach((stateKey) => { + let ev = roomData._currentState[evType][stateKey]; + if (rollBackState[evType] && rollBackState[evType][stateKey]) { + // use the reverse clobbered event instead. + ev = rollBackState[evType][stateKey]; + } + roomJson.state.events.push(ev); + }); + }); + data.join[roomId] = roomJson; + }); + + // Add account data + const accData = []; + Array.from(this.accountData.keys()).forEach((evType) => { + accData.push(this.accountData.get(evType)); + }); + + return { + nextBatch: this.nextBatch, + roomsData: data, + groupsData: this.groups, + accountData: accData, + }; + } + + getNextBatchToken() { + return this.nextBatch; + } +} + +function setState(eventMap, event) { + if (event.state_key === null || event.state_key === undefined || !event.type) { + return; + } + if (!eventMap[event.type]) { + eventMap[event.type] = Object.create(null); + } + eventMap[event.type][event.state_key] = event; +} + +module.exports = SyncAccumulator; diff --git a/matrix-js-sdk/src/sync.js b/matrix-js-sdk/src/sync.js new file mode 100644 index 000000000..12775903e --- /dev/null +++ b/matrix-js-sdk/src/sync.js @@ -0,0 +1,1700 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector 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. +*/ +"use strict"; + +/* + * 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 Promise from 'bluebird'; +const User = require("./models/user"); +const Room = require("./models/room"); +const Group = require('./models/group'); +const utils = require("./utils"); +const Filter = require("./filter"); +const EventTimeline = require("./models/event-timeline"); +import logger from '../src/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. + */ +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._peekRoomId = 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) { + const self = this; + const client = this.client; + this._peekRoomId = roomId; + return this.client.roomInitialSync(roomId, 20).then(function(response) { + // make sure things are init'd + response.messages = response.messages || {}; + response.messages.chunk = response.messages.chunk || []; + response.state = response.state || []; + + const peekRoom = self.createRoom(roomId); + + // FIXME: Mostly duplicated from _processRoomEvents but not entirely + // because "state" in this API is at the BEGINNING of the chunk + const oldStateEvents = utils.map( + utils.deepCopy(response.state), client.getEventMapper(), + ); + const stateEvents = utils.map( + response.state, client.getEventMapper(), + ); + const messages = utils.map( + response.messages.chunk, client.getEventMapper(), + ); + + // XXX: copypasted from /sync until we kill off this + // minging v1 API stuff) + // handle presence events (User objects) + if (response.presence && utils.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) { + peekRoom.oldState.paginationToken = response.messages.start; + } + + // set the state of the room to as it was after the timeline executes + peekRoom.oldState.setStateEvents(oldStateEvents); + peekRoom.currentState.setStateEvents(stateEvents); + + self._resolveInvites(peekRoom); + peekRoom.recalculate(); + + // roll backwards to diverge old state. addEventsToTimeline + // will overwrite the pagination token, so make sure it overwrites + // it with the right thing. + peekRoom.addEventsToTimeline(messages.reverse(), true, + peekRoom.getLiveTimeline(), + response.messages.start); + + client.store.storeRoom(peekRoom); + client.emit("Room", peekRoom); + + self._peekPoll(peekRoom); + return peekRoom; + }); +}; + +/** + * Stop polling for updates in the peeked room. NOPs if there is no room being + * peeked. + */ +SyncApi.prototype.stopPeeking = function() { + this._peekRoomId = null; +}; + +/** + * Do a peek room poll. + * @param {Room} peekRoom + * @param {string} token from= token + */ +SyncApi.prototype._peekPoll = function(peekRoom, token) { + if (this._peekRoomId !== peekRoom.roomId) { + 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).done(function(res) { + if (self._peekRoomId !== peekRoom.roomId) { + 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) + const events = res.chunk.filter(function(e) { + return e.room_id === peekRoom.roomId; + }).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.document) { + this._onOnlineBound = this._onOnline.bind(this); + global.document.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 + } + + 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) { + try { + debuglog("Creating and storing lazy load sync filter..."); + this.opts.filter = await client.createFilter( + Filter.LAZY_LOADING_SYNC_FILTER, + ); + debuglog("Created and stored lazy load sync filter"); + } catch (err) { + logger.error( + "Creating and storing lazy load sync filter failed", + err, + ); + throw err; + } + } 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 = new Filter(client.credentials.userId); + filter.setTimelineLimit(self.opts.initialSyncLimit); + } + + 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.document) { + global.document.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, + }; + + 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 && utils.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 && utils.isArray(data.account_data.events)) { + const events = data.account_data.events.map(client.getEventMapper()); + 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 recieve push rules via getPushRules + // (see SyncApi.prototype.sync) before syncing over the network. + if (accountDataEvent.getType() == 'm.push_rules') { + client.pushRules = accountDataEvent.getContent(); + } + client.emit("accountData", accountDataEvent); + return accountDataEvent; + }, + ); + } + + // handle to-device events + if (data.to_device && utils.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 Promise.mapSeries(joinRooms, async function(joinObj) { + const room = joinObj.room; + const stateEvents = self._mapSyncEventsFormat(joinObj.state, room); + const timelineEvents = self._mapSyncEventsFormat(joinObj.timeline, room); + const ephemeralEvents = self._mapSyncEventsFormat(joinObj.ephemeral); + const accountDataEvents = self._mapSyncEventsFormat(joinObj.account_data); + + // 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. + const encrypted = client.isRoomEncrypted(room.roomId); + 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); + + // 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 Promise.mapSeries(stateEvents, processRoomEvent); + await Promise.mapSeries(timelineEvents, processRoomEvent); + ephemeralEvents.forEach(function(e) { + client.emit("event", e); + }); + accountDataEvents.forEach(function(e) { + client.emit("event", e); + }); + + room.updateMyMembership("join"); + }); + + // 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); + } +}; + +/** + * 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 = Promise.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, + }, + ).done(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 utils.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 + * @return {MatrixEvent[]} + */ +SyncApi.prototype._mapSyncEventsFormat = function(obj, room) { + if (!obj || !utils.isArray(obj.events)) { + return []; + } + const mapper = this.client.getEventMapper(); + 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.done(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 + * is earlier in time. Higher index is later. + */ +SyncApi.prototype._processRoomEvents = function(room, stateEventList, + timelineEventList) { + // 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 || []); +}; + +/** + * 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() { + const guestRooms = this.client._guestRooms; // FIXME: horrible gut-wrenching + if (!guestRooms) { + return "{}"; + } + // we just need to specify the filter inline if we're a guest because guests + // can't create filters. + return JSON.stringify({ + room: { + timeline: { + limit: 20, + }, + }, + }); +}; + +/** + * 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; +} + +/** */ +module.exports = SyncApi; diff --git a/matrix-js-sdk/src/timeline-window.js b/matrix-js-sdk/src/timeline-window.js new file mode 100644 index 000000000..d2ec174d2 --- /dev/null +++ b/matrix-js-sdk/src/timeline-window.js @@ -0,0 +1,502 @@ +/* +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. +*/ +"use strict"; + +/** @module timeline-window */ + +import Promise from 'bluebird'; +const EventTimeline = require("./models/event-timeline"); +import logger from '../src/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 + */ +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 {module:client.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 prom = this._client.getEventTimeline(this._timelineSet, initialEventId); + + if (prom.isFulfilled()) { + initFields(prom.value()); + return Promise.resolve(); + } else { + return prom.then(initFields); + } + } else { + const tl = this._timelineSet.getLiveTimeline(); + initFields(tl); + return Promise.resolve(); + } +}; + +/** + * 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) { + let tl; + if (direction == EventTimeline.BACKWARDS) { + tl = this._start; + } else if (direction == EventTimeline.FORWARDS) { + tl = this._end; + } else { + throw new Error("Invalid direction '" + 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 {module:client.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; + } + + let tl; + if (direction == EventTimeline.BACKWARDS) { + tl = this._start; + } else if (direction == EventTimeline.FORWARDS) { + tl = this._end; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + + if (!tl) { + debuglog("TimelineWindow: no timeline yet"); + return Promise.resolve(false); + } + + if (tl.pendingPaginate) { + return tl.pendingPaginate; + } + + // try moving the cap + 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 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, 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 + */ +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; +}; + +/** + * The TimelineWindow class. + */ +module.exports.TimelineWindow = TimelineWindow; + +/** + * The TimelineIndex class. exported here for unit testing. + */ +module.exports.TimelineIndex = TimelineIndex; diff --git a/matrix-js-sdk/src/utils.js b/matrix-js-sdk/src/utils.js new file mode 100644 index 000000000..d05697f19 --- /dev/null +++ b/matrix-js-sdk/src/utils.js @@ -0,0 +1,701 @@ +/* +Copyright 2015, 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. +*/ +"use strict"; +/** + * This is an internal module. + * @module utils + */ + +const unhomoglyph = require('unhomoglyph'); + +/** + * Encode a dictionary of query parameters. + * @param {Object} params A dict of key/values to encode e.g. + * {"foo": "bar", "baz": "taz"} + * @return {string} The encoded string e.g. foo=bar&baz=taz + */ +module.exports.encodeParams = function(params) { + let qs = ""; + for (const key in params) { + if (!params.hasOwnProperty(key)) { + continue; + } + qs += "&" + encodeURIComponent(key) + "=" + + encodeURIComponent(params[key]); + } + return qs.substring(1); +}; + +/** + * Encodes a URI according to a set of template variables. Variables will be + * passed through encodeURIComponent. + * @param {string} pathTemplate The path with template variables e.g. '/foo/$bar'. + * @param {Object} variables The key/value pairs to replace the template + * variables with. E.g. { "$bar": "baz" }. + * @return {string} The result of replacing all template variables e.g. '/foo/baz'. + */ +module.exports.encodeUri = function(pathTemplate, variables) { + for (const key in variables) { + if (!variables.hasOwnProperty(key)) { + continue; + } + pathTemplate = pathTemplate.replace( + key, encodeURIComponent(variables[key]), + ); + } + return pathTemplate; +}; + +/** + * Applies a map function to the given array. + * @param {Array} array The array to apply the function to. + * @param {Function} fn The function that will be invoked for each element in + * the array with the signature fn(element){...} + * @return {Array} A new array with the results of the function. + */ +module.exports.map = function(array, fn) { + const results = new Array(array.length); + for (let i = 0; i < array.length; i++) { + results[i] = fn(array[i]); + } + return results; +}; + +/** + * Applies a filter function to the given array. + * @param {Array} array The array to apply the function to. + * @param {Function} fn The function that will be invoked for each element in + * the array. It should return true to keep the element. The function signature + * looks like fn(element, index, array){...}. + * @return {Array} A new array with the results of the function. + */ +module.exports.filter = function(array, fn) { + const results = []; + for (let i = 0; i < array.length; i++) { + if (fn(array[i], i, array)) { + results.push(array[i]); + } + } + return results; +}; + +/** + * Get the keys for an object. Same as Object.keys(). + * @param {Object} obj The object to get the keys for. + * @return {string[]} The keys of the object. + */ +module.exports.keys = function(obj) { + const keys = []; + for (const key in obj) { + if (!obj.hasOwnProperty(key)) { + continue; + } + keys.push(key); + } + return keys; +}; + +/** + * Get the values for an object. + * @param {Object} obj The object to get the values for. + * @return {Array<*>} The values of the object. + */ +module.exports.values = function(obj) { + const values = []; + for (const key in obj) { + if (!obj.hasOwnProperty(key)) { + continue; + } + values.push(obj[key]); + } + return values; +}; + +/** + * Invoke a function for each item in the array. + * @param {Array} array The array. + * @param {Function} fn The function to invoke for each element. Has the + * function signature fn(element, index). + */ +module.exports.forEach = function(array, fn) { + for (let i = 0; i < array.length; i++) { + fn(array[i], i); + } +}; + +/** + * The findElement() method returns a value in the array, if an element in the array + * satisfies (returns true) the provided testing function. Otherwise undefined + * is returned. + * @param {Array} array The array. + * @param {Function} fn Function to execute on each value in the array, with the + * function signature fn(element, index, array) + * @param {boolean} reverse True to search in reverse order. + * @return {*} The first value in the array which returns true for + * the given function. + */ +module.exports.findElement = function(array, fn, reverse) { + let i; + if (reverse) { + for (i = array.length - 1; i >= 0; i--) { + if (fn(array[i], i, array)) { + return array[i]; + } + } + } else { + for (i = 0; i < array.length; i++) { + if (fn(array[i], i, array)) { + return array[i]; + } + } + } +}; + +/** + * The removeElement() method removes the first element in the array that + * satisfies (returns true) the provided testing function. + * @param {Array} array The array. + * @param {Function} fn Function to execute on each value in the array, with the + * function signature fn(element, index, array). Return true to + * remove this element and break. + * @param {boolean} reverse True to search in reverse order. + * @return {boolean} True if an element was removed. + */ +module.exports.removeElement = function(array, fn, reverse) { + let i; + let removed; + if (reverse) { + for (i = array.length - 1; i >= 0; i--) { + if (fn(array[i], i, array)) { + removed = array[i]; + array.splice(i, 1); + return removed; + } + } + } else { + for (i = 0; i < array.length; i++) { + if (fn(array[i], i, array)) { + removed = array[i]; + array.splice(i, 1); + return removed; + } + } + } + return false; +}; + +/** + * Checks if the given thing is a function. + * @param {*} value The thing to check. + * @return {boolean} True if it is a function. + */ +module.exports.isFunction = function(value) { + return Object.prototype.toString.call(value) == "[object Function]"; +}; + +/** + * Checks if the given thing is an array. + * @param {*} value The thing to check. + * @return {boolean} True if it is an array. + */ +module.exports.isArray = function(value) { + return Array.isArray ? Array.isArray(value) : + Boolean(value && value.constructor === Array); +}; + +/** + * Checks that the given object has the specified keys. + * @param {Object} obj The object to check. + * @param {string[]} keys The list of keys that 'obj' must have. + * @throws If the object is missing keys. + */ +module.exports.checkObjectHasKeys = function(obj, keys) { + for (let i = 0; i < keys.length; i++) { + if (!obj.hasOwnProperty(keys[i])) { + throw new Error("Missing required key: " + keys[i]); + } + } +}; + +/** + * Checks that the given object has no extra keys other than the specified ones. + * @param {Object} obj The object to check. + * @param {string[]} allowedKeys The list of allowed key names. + * @throws If there are extra keys. + */ +module.exports.checkObjectHasNoAdditionalKeys = function(obj, allowedKeys) { + for (const key in obj) { + if (!obj.hasOwnProperty(key)) { + continue; + } + if (allowedKeys.indexOf(key) === -1) { + throw new Error("Unknown key: " + key); + } + } +}; + +/** + * Deep copy the given object. The object MUST NOT have circular references and + * MUST NOT have functions. + * @param {Object} obj The object to deep copy. + * @return {Object} A copy of the object without any references to the original. + */ +module.exports.deepCopy = function(obj) { + return JSON.parse(JSON.stringify(obj)); +}; + +/** + * Compare two objects for equality. The objects MUST NOT have circular references. + * + * @param {Object} x The first object to compare. + * @param {Object} y The second object to compare. + * + * @return {boolean} true if the two objects are equal + */ +const deepCompare = module.exports.deepCompare = function(x, y) { + // Inspired by + // http://stackoverflow.com/questions/1068834/object-comparison-in-javascript#1144249 + + // Compare primitives and functions. + // Also check if both arguments link to the same object. + if (x === y) { + return true; + } + + if (typeof x !== typeof y) { + return false; + } + + // special-case NaN (since NaN !== NaN) + if (typeof x === 'number' && isNaN(x) && isNaN(y)) { + return true; + } + + // special-case null (since typeof null == 'object', but null.constructor + // throws) + if (x === null || y === null) { + return x === y; + } + + // everything else is either an unequal primitive, or an object + if (!(x instanceof Object)) { + return false; + } + + // check they are the same type of object + if (x.constructor !== y.constructor || x.prototype !== y.prototype) { + return false; + } + + // special-casing for some special types of object + if (x instanceof RegExp || x instanceof Date) { + return x.toString() === y.toString(); + } + + // the object algorithm works for Array, but it's sub-optimal. + if (x instanceof Array) { + if (x.length !== y.length) { + return false; + } + + for (let i = 0; i < x.length; i++) { + if (!deepCompare(x[i], y[i])) { + return false; + } + } + } else { + // disable jshint "The body of a for in should be wrapped in an if + // statement" + /* jshint -W089 */ + + // check that all of y's direct keys are in x + let p; + for (p in y) { + if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { + return false; + } + } + + // finally, compare each of x's keys with y + for (p in y) { // eslint-disable-line guard-for-in + if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { + return false; + } + if (!deepCompare(x[p], y[p])) { + return false; + } + } + } + /* jshint +W089 */ + return true; +}; + +/** + * Copy properties from one object to another. + * + * All enumerable properties, included inherited ones, are copied. + * + * This is approximately equivalent to ES6's Object.assign, except + * that the latter doesn't copy inherited properties. + * + * @param {Object} target The object that will receive new properties + * @param {...Object} source Objects from which to copy properties + * + * @return {Object} target + */ +module.exports.extend = function() { + const target = arguments[0] || {}; + for (let i = 1; i < arguments.length; i++) { + const source = arguments[i]; + for (const propName in source) { // eslint-disable-line guard-for-in + target[propName] = source[propName]; + } + } + return target; +}; + +/** + * Run polyfills to add Array.map and Array.filter if they are missing. + */ +module.exports.runPolyfills = function() { + // Array.prototype.filter + // ======================================================== + // SOURCE: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter + if (!Array.prototype.filter) { + Array.prototype.filter = function(fun/*, thisArg*/) { + if (this === void 0 || this === null) { + throw new TypeError(); + } + + const t = Object(this); + const len = t.length >>> 0; + if (typeof fun !== 'function') { + throw new TypeError(); + } + + const res = []; + const thisArg = arguments.length >= 2 ? arguments[1] : void 0; + for (let i = 0; i < len; i++) { + if (i in t) { + const val = t[i]; + + // NOTE: Technically this should Object.defineProperty at + // the next index, as push can be affected by + // properties on Object.prototype and Array.prototype. + // But that method's new, and collisions should be + // rare, so use the more-compatible alternative. + if (fun.call(thisArg, val, i, t)) { + res.push(val); + } + } + } + + return res; + }; + } + + // Array.prototype.map + // ======================================================== + // SOURCE: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map + // Production steps of ECMA-262, Edition 5, 15.4.4.19 + // Reference: http://es5.github.io/#x15.4.4.19 + if (!Array.prototype.map) { + Array.prototype.map = function(callback, thisArg) { + let T, k; + + if (this === null || this === undefined) { + throw new TypeError(' this is null or not defined'); + } + + // 1. Let O be the result of calling ToObject passing the |this| + // value as the argument. + const O = Object(this); + + // 2. Let lenValue be the result of calling the Get internal + // method of O with the argument "length". + // 3. Let len be ToUint32(lenValue). + const len = O.length >>> 0; + + // 4. If IsCallable(callback) is false, throw a TypeError exception. + // See: http://es5.github.com/#x9.11 + if (typeof callback !== 'function') { + throw new TypeError(callback + ' is not a function'); + } + + // 5. If thisArg was supplied, let T be thisArg; else let T be undefined. + if (arguments.length > 1) { + T = thisArg; + } + + // 6. Let A be a new array created as if by the expression new Array(len) + // where Array is the standard built-in constructor with that name and + // len is the value of len. + const A = new Array(len); + + // 7. Let k be 0 + k = 0; + + // 8. Repeat, while k < len + while (k < len) { + var kValue, mappedValue; + + // a. Let Pk be ToString(k). + // This is implicit for LHS operands of the in operator + // b. Let kPresent be the result of calling the HasProperty internal + // method of O with argument Pk. + // This step can be combined with c + // c. If kPresent is true, then + if (k in O) { + // i. Let kValue be the result of calling the Get internal + // method of O with argument Pk. + kValue = O[k]; + + // ii. Let mappedValue be the result of calling the Call internal + // method of callback with T as the this value and argument + // list containing kValue, k, and O. + mappedValue = callback.call(T, kValue, k, O); + + // iii. Call the DefineOwnProperty internal method of A with arguments + // Pk, Property Descriptor + // { Value: mappedValue, + // Writable: true, + // Enumerable: true, + // Configurable: true }, + // and false. + + // In browsers that support Object.defineProperty, use the following: + // Object.defineProperty(A, k, { + // value: mappedValue, + // writable: true, + // enumerable: true, + // configurable: true + // }); + + // For best browser support, use the following: + A[k] = mappedValue; + } + // d. Increase k by 1. + k++; + } + + // 9. return A + return A; + }; + } + + // Array.prototype.forEach + // ======================================================== + // SOURCE: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach + // Production steps of ECMA-262, Edition 5, 15.4.4.18 + // Reference: http://es5.github.io/#x15.4.4.18 + if (!Array.prototype.forEach) { + Array.prototype.forEach = function(callback, thisArg) { + let T, k; + + if (this === null || this === undefined) { + throw new TypeError(' this is null or not defined'); + } + + // 1. Let O be the result of calling ToObject passing the |this| value as the + // argument. + const O = Object(this); + + // 2. Let lenValue be the result of calling the Get internal method of O with the + // argument "length". + // 3. Let len be ToUint32(lenValue). + const len = O.length >>> 0; + + // 4. If IsCallable(callback) is false, throw a TypeError exception. + // See: http://es5.github.com/#x9.11 + if (typeof callback !== "function") { + throw new TypeError(callback + ' is not a function'); + } + + // 5. If thisArg was supplied, let T be thisArg; else let T be undefined. + if (arguments.length > 1) { + T = thisArg; + } + + // 6. Let k be 0 + k = 0; + + // 7. Repeat, while k < len + while (k < len) { + var kValue; + + // a. Let Pk be ToString(k). + // This is implicit for LHS operands of the in operator + // b. Let kPresent be the result of calling the HasProperty internal + // method of O with + // argument Pk. + // This step can be combined with c + // c. If kPresent is true, then + if (k in O) { + // i. Let kValue be the result of calling the Get internal method of O with + // argument Pk + kValue = O[k]; + + // ii. Call the Call internal method of callback with T as the this value and + // argument list containing kValue, k, and O. + callback.call(T, kValue, k, O); + } + // d. Increase k by 1. + k++; + } + // 8. return undefined + }; + } +}; + +/** + * Inherit the prototype methods from one constructor into another. This is a + * port of the Node.js implementation with an Object.create polyfill. + * + * @param {function} ctor Constructor function which needs to inherit the + * prototype. + * @param {function} superCtor Constructor function to inherit prototype from. + */ +module.exports.inherits = function(ctor, superCtor) { + // Add Object.create polyfill for IE8 + // Source: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript + // /Reference/Global_Objects/Object/create#Polyfill + if (typeof Object.create != 'function') { + // Production steps of ECMA-262, Edition 5, 15.2.3.5 + // Reference: http://es5.github.io/#x15.2.3.5 + Object.create = (function() { + // To save on memory, use a shared constructor + function Temp() {} + + // make a safe reference to Object.prototype.hasOwnProperty + const hasOwn = Object.prototype.hasOwnProperty; + + return function(O) { + // 1. If Type(O) is not Object or Null throw a TypeError exception. + if (typeof O != 'object') { + throw new TypeError('Object prototype may only be an Object or null'); + } + + // 2. Let obj be the result of creating a new object as if by the + // expression new Object() where Object is the standard built-in + // constructor with that name + // 3. Set the [[Prototype]] internal property of obj to O. + Temp.prototype = O; + const obj = new Temp(); + Temp.prototype = null; // Let's not keep a stray reference to O... + + // 4. If the argument Properties is present and not undefined, add + // own properties to obj as if by calling the standard built-in + // function Object.defineProperties with arguments obj and + // Properties. + if (arguments.length > 1) { + // Object.defineProperties does ToObject on its first argument. + const Properties = Object(arguments[1]); + for (const prop in Properties) { + if (hasOwn.call(Properties, prop)) { + obj[prop] = Properties[prop]; + } + } + } + + // 5. Return obj + return obj; + }; + })(); + } + // END polyfill + + // Add util.inherits from Node.js + // Source: + // https://github.com/joyent/node/blob/master/lib/util.js + // Copyright Joyent, Inc. and other Node contributors. + // + // Permission is hereby granted, free of charge, to any person obtaining a + // copy of this software and associated documentation files (the + // "Software"), to deal in the Software without restriction, including + // without limitation the rights to use, copy, modify, merge, publish, + // distribute, sublicense, and/or sell copies of the Software, and to permit + // persons to whom the Software is furnished to do so, subject to the + // following conditions: + // + // The above copyright notice and this permission notice shall be included + // in all copies or substantial portions of the Software. + // + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN + // NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE + // USE OR OTHER DEALINGS IN THE SOFTWARE. + ctor.super_ = superCtor; + ctor.prototype = Object.create(superCtor.prototype, { + constructor: { + value: ctor, + enumerable: false, + writable: true, + configurable: true, + }, + }); +}; + +/** + * Returns whether the given value is a finite number without type-coercion + * + * @param {*} value the value to test + * @return {boolean} whether or not value is a finite number without type-coercion + */ +module.exports.isNumber = function(value) { + return typeof value === 'number' && isFinite(value); +}; + +/** + * Removes zero width chars, diacritics and whitespace from the string + * Also applies an unhomoglyph on the string, to prevent similar looking chars + * @param {string} str the string to remove hidden characters from + * @return {string} a string with the hidden characters removed + */ +module.exports.removeHiddenChars = function(str) { + return unhomoglyph(str.normalize('NFD').replace(removeHiddenCharsRegex, '')); +}; +const removeHiddenCharsRegex = /[\u200B-\u200D\u0300-\u036f\uFEFF\s]/g; + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +module.exports.escapeRegExp = escapeRegExp; + +module.exports.globToRegexp = function(glob, extended) { + extended = typeof(extended) === 'boolean' ? extended : true; + // From + // https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132 + // Because micromatch is about 130KB with dependencies, + // and minimatch is not much better. + let pat = escapeRegExp(glob); + pat = pat.replace(/\\\*/g, '.*'); + pat = pat.replace(/\?/g, '.'); + if (extended) { + pat = pat.replace(/\\\[(!|)(.*)\\]/g, function(match, p1, p2, offset, string) { + const first = p1 && '^' || ''; + const second = p2.replace(/\\-/, '-'); + return '[' + first + second + ']'; + }); + } + return pat; +}; diff --git a/matrix-js-sdk/src/webrtc/call.js b/matrix-js-sdk/src/webrtc/call.js new file mode 100644 index 000000000..97ab3844e --- /dev/null +++ b/matrix-js-sdk/src/webrtc/call.js @@ -0,0 +1,1421 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector 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. +*/ +"use strict"; +/** + * This is an internal module. See {@link createNewMatrixCall} for the public API. + * @module webrtc/call + */ +const utils = require("../utils"); +const EventEmitter = require("events").EventEmitter; +import logger from '../../src/logger'; +const DEBUG = true; // set true to enable console logging. + +// events: hangup, error(err), replaced(call), state(state, oldState) + +/** + * Fires whenever an error occurs when call.js encounters an issue with setting up the call. + *

    + * The error given will have a code equal to either `MatrixCall.ERR_LOCAL_OFFER_FAILED` or + * `MatrixCall.ERR_NO_USER_MEDIA`. `ERR_LOCAL_OFFER_FAILED` is emitted when the local client + * fails to create an offer. `ERR_NO_USER_MEDIA` is emitted when the user has denied access + * to their audio/video hardware. + * + * @event module:webrtc/call~MatrixCall#"error" + * @param {Error} err The error raised by MatrixCall. + * @example + * matrixCall.on("error", function(err){ + * console.error(err.code, err); + * }); + */ + +/** + * Construct a new Matrix Call. + * @constructor + * @param {Object} opts Config options. + * @param {string} opts.roomId The room ID for this call. + * @param {Object} opts.webRtc The WebRTC globals from the browser. + * @param {boolean} opts.forceTURN whether relay through TURN should be forced. + * @param {Object} opts.URL The URL global. + * @param {Array} opts.turnServers Optional. A list of TURN servers. + * @param {MatrixClient} opts.client The Matrix Client instance to send events to. + */ +function MatrixCall(opts) { + this.roomId = opts.roomId; + this.client = opts.client; + this.webRtc = opts.webRtc; + this.forceTURN = opts.forceTURN; + this.URL = opts.URL; + // Array of Objects with urls, username, credential keys + this.turnServers = opts.turnServers || []; + if (this.turnServers.length === 0) { + this.turnServers.push({ + urls: [MatrixCall.FALLBACK_STUN_SERVER], + }); + } + utils.forEach(this.turnServers, function(server) { + utils.checkObjectHasKeys(server, ["urls"]); + }); + + this.callId = "c" + new Date().getTime() + Math.random(); + this.state = 'fledgling'; + this.didConnect = false; + + // A queue for candidates waiting to go out. + // We try to amalgamate candidates into a single candidate message where + // possible + this.candidateSendQueue = []; + this.candidateSendTries = 0; + + // Lookup from opaque queue ID to a promise for media element operations that + // need to be serialised into a given queue. Store this per-MatrixCall on the + // assumption that multiple matrix calls will never compete for control of the + // same DOM elements. + this.mediaPromises = Object.create(null); + + this.screenSharingStream = null; + + this._answerContent = null; +} +/** The length of time a call can be ringing for. */ +MatrixCall.CALL_TIMEOUT_MS = 60000; +/** The fallback server to use for STUN. */ +MatrixCall.FALLBACK_STUN_SERVER = 'stun:stun.l.google.com:19302'; +/** An error code when the local client failed to create an offer. */ +MatrixCall.ERR_LOCAL_OFFER_FAILED = "local_offer_failed"; +/** + * An error code when there is no local mic/camera to use. This may be because + * the hardware isn't plugged in, or the user has explicitly denied access. + */ +MatrixCall.ERR_NO_USER_MEDIA = "no_user_media"; + +/* + * Error code used when a call event failed to send + * because unknown devices were present in the room + */ +MatrixCall.ERR_UNKNOWN_DEVICES = "unknown_devices"; + +/* + * Error code usewd when we fail to send the invite + * for some reason other than there being unknown devices + */ +MatrixCall.ERR_SEND_INVITE = "send_invite"; + +/* + * Error code usewd when we fail to send the answer + * for some reason other than there being unknown devices + */ +MatrixCall.ERR_SEND_ANSWER = "send_answer"; + +utils.inherits(MatrixCall, EventEmitter); + +/** + * Place a voice call to this room. + * @throws If you have not specified a listener for 'error' events. + */ +MatrixCall.prototype.placeVoiceCall = function() { + debuglog("placeVoiceCall"); + checkForErrorListener(this); + _placeCallWithConstraints(this, _getUserMediaVideoContraints('voice')); + this.type = 'voice'; +}; + +/** + * Place a video call to this room. + * @param {Element} remoteVideoElement a <video> DOM element + * to render video to. + * @param {Element} localVideoElement a <video> DOM element + * to render the local camera preview. + * @throws If you have not specified a listener for 'error' events. + */ +MatrixCall.prototype.placeVideoCall = function(remoteVideoElement, localVideoElement) { + debuglog("placeVideoCall"); + checkForErrorListener(this); + this.localVideoElement = localVideoElement; + this.remoteVideoElement = remoteVideoElement; + _placeCallWithConstraints(this, _getUserMediaVideoContraints('video')); + this.type = 'video'; + _tryPlayRemoteStream(this); +}; + +/** + * Place a screen-sharing call to this room. This includes audio. + * This method is EXPERIMENTAL and subject to change without warning. It + * only works in Google Chrome and Firefox >= 44. + * @param {Element} remoteVideoElement a <video> DOM element + * to render video to. + * @param {Element} localVideoElement a <video> DOM element + * to render the local camera preview. + * @throws If you have not specified a listener for 'error' events. + */ +MatrixCall.prototype.placeScreenSharingCall = + function(remoteVideoElement, localVideoElement) { + debuglog("placeScreenSharingCall"); + checkForErrorListener(this); + const screenConstraints = _getScreenSharingConstraints(this); + if (!screenConstraints) { + return; + } + this.localVideoElement = localVideoElement; + this.remoteVideoElement = remoteVideoElement; + const self = this; + this.webRtc.getUserMedia(screenConstraints, function(stream) { + self.screenSharingStream = stream; + debuglog("Got screen stream, requesting audio stream..."); + const audioConstraints = _getUserMediaVideoContraints('voice'); + _placeCallWithConstraints(self, audioConstraints); + }, function(err) { + self.emit("error", + callError( + MatrixCall.ERR_NO_USER_MEDIA, + "Failed to get screen-sharing stream: " + err, + ), + ); + }); + this.type = 'video'; + _tryPlayRemoteStream(this); +}; + +/** + * Play the given HTMLMediaElement, serialising the operation into a chain + * of promises to avoid racing access to the element + * @param {Element} element HTMLMediaElement element to play + * @param {string} queueId Arbitrary ID to track the chain of promises to be used + */ +MatrixCall.prototype.playElement = function(element, queueId) { + logger.log("queuing play on " + queueId + " and element " + element); + // XXX: FIXME: Does this leak elements, given the old promises + // may hang around and retain a reference to them? + if (this.mediaPromises[queueId]) { + // XXX: these promises can fail (e.g. by