pax_global_header00006660000000000000000000000064140350466250014517gustar00rootroot0000000000000052 comment=c7f887131edafce1b6c0830089a299517735ad5c matrix-js-sdk-9.11.0/000077500000000000000000000000001403504662500143045ustar00rootroot00000000000000matrix-js-sdk-9.11.0/.babelrc000066400000000000000000000007641403504662500157060ustar00rootroot00000000000000{ "sourceMaps": true, "presets": [ ["@babel/preset-env", { "targets": { "node": 10 }, "modules": "commonjs" }], "@babel/preset-typescript" ], "plugins": [ "@babel/plugin-proposal-numeric-separator", "@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-object-rest-spread", "@babel/plugin-syntax-dynamic-import", "@babel/plugin-transform-runtime" ] } matrix-js-sdk-9.11.0/.editorconfig000066400000000000000000000013511403504662500167610ustar00rootroot00000000000000# 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 matrix-js-sdk-9.11.0/.eslintrc.js000066400000000000000000000024631403504662500165500ustar00rootroot00000000000000module.exports = { extends: ["matrix-org"], plugins: [ "babel", ], env: { browser: true, node: true, }, rules: { "no-var": ["warn"], "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", "prefer-promise-reject-errors": "off", "quotes": "off", "indent": "off", "no-constant-condition": "off", "no-async-promise-executor": "off", // We use a `logger` intermediary module "no-console": "error", }, overrides: [{ "files": ["src/**/*.ts"], "extends": ["matrix-org/ts"], "rules": { // We're okay being explicit at the moment "@typescript-eslint/no-empty-interface": "off", // While we're converting to ts we make heavy use of this "@typescript-eslint/no-explicit-any": "off", "quotes": "off", }, }], }; matrix-js-sdk-9.11.0/.github/000077500000000000000000000000001403504662500156445ustar00rootroot00000000000000matrix-js-sdk-9.11.0/.github/FUNDING.yml000066400000000000000000000000561403504662500174620ustar00rootroot00000000000000patreon: matrixdotorg liberapay: matrixdotorg matrix-js-sdk-9.11.0/.gitignore000066400000000000000000000003471403504662500163000ustar00rootroot00000000000000/.jsdocbuild /.jsdoc node_modules /.npmrc /*.log package-lock.json .lock-wscript build/Release coverage lib-cov out /dist /lib # version file and tarball created by `npm pack` / `yarn pack` /git-revision.txt /matrix-js-sdk-*.tgz matrix-js-sdk-9.11.0/.istanbul.yml000066400000000000000000000000421403504662500167220ustar00rootroot00000000000000instrumentation: compact: false matrix-js-sdk-9.11.0/CHANGELOG.md000066400000000000000000006342261403504662500161320ustar00rootroot00000000000000Changes in [9.11.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.11.0) (2021-04-12) ================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.11.0-rc.1...v9.11.0) * No changes since rc.1 Changes in [9.11.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.11.0-rc.1) (2021-04-07) ============================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.10.0...v9.11.0-rc.1) * Only try to cache private keys we know exist [\#1657](https://github.com/matrix-org/matrix-js-sdk/pull/1657) * Properly terminate screen-share calls if NoUserMedia [\#1654](https://github.com/matrix-org/matrix-js-sdk/pull/1654) * Attended transfer [\#1652](https://github.com/matrix-org/matrix-js-sdk/pull/1652) * Remove catch handlers in private key retrieval [\#1653](https://github.com/matrix-org/matrix-js-sdk/pull/1653) * Fixed the media fail error on caller's side [\#1651](https://github.com/matrix-org/matrix-js-sdk/pull/1651) * Add function to share megolm keys for historical messages, take 2 [\#1640](https://github.com/matrix-org/matrix-js-sdk/pull/1640) * Cache cross-signing private keys if needed on bootstrap [\#1649](https://github.com/matrix-org/matrix-js-sdk/pull/1649) Changes in [9.10.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.10.0) (2021-03-29) ================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.10.0-rc.1...v9.10.0) * No changes since rc.1 Changes in [9.10.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.10.0-rc.1) (2021-03-25) ============================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.9.0...v9.10.0-rc.1) * Don't send m.call.hangup if m.call.invite wasn't sent either [\#1647](https://github.com/matrix-org/matrix-js-sdk/pull/1647) * docs: registerGuest() [\#1641](https://github.com/matrix-org/matrix-js-sdk/pull/1641) * Download device keys in chunks of 250 [\#1639](https://github.com/matrix-org/matrix-js-sdk/pull/1639) * More VoIP connectivity fixes [\#1646](https://github.com/matrix-org/matrix-js-sdk/pull/1646) * Make selectDesktopCapturerSource param optional [\#1644](https://github.com/matrix-org/matrix-js-sdk/pull/1644) * Expose APIs needed for reworked cross-signing login flow [\#1632](https://github.com/matrix-org/matrix-js-sdk/pull/1632) Changes in [9.9.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.9.0) (2021-03-15) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.9.0-rc.1...v9.9.0) * No changes since rc.1 Changes in [9.9.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.9.0-rc.1) (2021-03-10) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.8.0...v9.9.0-rc.1) * Remove detailed Olm session logging [\#1638](https://github.com/matrix-org/matrix-js-sdk/pull/1638) * Add space summary suggested only param [\#1637](https://github.com/matrix-org/matrix-js-sdk/pull/1637) * Check TURN servers periodically, and at start of calls [\#1634](https://github.com/matrix-org/matrix-js-sdk/pull/1634) * Support sending invite reasons [\#1624](https://github.com/matrix-org/matrix-js-sdk/pull/1624) * Bump elliptic from 6.5.3 to 6.5.4 [\#1636](https://github.com/matrix-org/matrix-js-sdk/pull/1636) * Add a function to get a room's MXC URI [\#1635](https://github.com/matrix-org/matrix-js-sdk/pull/1635) * Stop streams if the call has ended [\#1633](https://github.com/matrix-org/matrix-js-sdk/pull/1633) * Remove export keyword from global.d.ts [\#1631](https://github.com/matrix-org/matrix-js-sdk/pull/1631) * Fix IndexedDB store creation example [\#1445](https://github.com/matrix-org/matrix-js-sdk/pull/1445) * An attempt to cleanup how constraints are handled in calls [\#1613](https://github.com/matrix-org/matrix-js-sdk/pull/1613) * Extract display name patterns to constants [\#1628](https://github.com/matrix-org/matrix-js-sdk/pull/1628) * Bump pug-code-gen from 2.0.2 to 2.0.3 [\#1630](https://github.com/matrix-org/matrix-js-sdk/pull/1630) * Avoid deadlocks when ensuring Olm sessions for devices [\#1627](https://github.com/matrix-org/matrix-js-sdk/pull/1627) * Filter out edits from other senders in history [\#1626](https://github.com/matrix-org/matrix-js-sdk/pull/1626) * Fix ContentHelpers export [\#1618](https://github.com/matrix-org/matrix-js-sdk/pull/1618) * Add logging to in progress Olm sessions [\#1621](https://github.com/matrix-org/matrix-js-sdk/pull/1621) * Don't ignore ICE candidates received before offer/answer [\#1623](https://github.com/matrix-org/matrix-js-sdk/pull/1623) * Better handling of send failures on VoIP events [\#1622](https://github.com/matrix-org/matrix-js-sdk/pull/1622) * Log when turn creds expire [\#1620](https://github.com/matrix-org/matrix-js-sdk/pull/1620) * Initial Spaces [MSC1772] support [\#1563](https://github.com/matrix-org/matrix-js-sdk/pull/1563) * Add logging to crypto store transactions [\#1617](https://github.com/matrix-org/matrix-js-sdk/pull/1617) * Room helper for getting type and checking if it is a space room [\#1610](https://github.com/matrix-org/matrix-js-sdk/pull/1610) Changes in [9.8.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.8.0) (2021-03-01) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.8.0-rc.1...v9.8.0) * No changes since rc.1 Changes in [9.8.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.8.0-rc.1) (2021-02-24) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.7.0...v9.8.0-rc.1) * Optimise prefixed logger [\#1615](https://github.com/matrix-org/matrix-js-sdk/pull/1615) * Add debug logs to encryption prep, take 3 [\#1614](https://github.com/matrix-org/matrix-js-sdk/pull/1614) * Add functions for upper & lowercase random strings [\#1612](https://github.com/matrix-org/matrix-js-sdk/pull/1612) * Room helpers for invite permissions and join rules [\#1609](https://github.com/matrix-org/matrix-js-sdk/pull/1609) * Fixed wording in "Adding video track with id" log [\#1606](https://github.com/matrix-org/matrix-js-sdk/pull/1606) * Add more debug logs to encryption prep [\#1605](https://github.com/matrix-org/matrix-js-sdk/pull/1605) * Add option to set ice candidate pool size [\#1604](https://github.com/matrix-org/matrix-js-sdk/pull/1604) * Cancel call if no source was selected [\#1601](https://github.com/matrix-org/matrix-js-sdk/pull/1601) Changes in [9.7.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.7.0) (2021-02-16) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.7.0-rc.1...v9.7.0) * No changes since rc.1 Changes in [9.7.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.7.0-rc.1) (2021-02-10) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.6.0...v9.7.0-rc.1) * Handle undefined peerconn [\#1600](https://github.com/matrix-org/matrix-js-sdk/pull/1600) * ReEmitter: Don't throw if no error handler is attached [\#1599](https://github.com/matrix-org/matrix-js-sdk/pull/1599) * Convert ReEmitter to TS [\#1598](https://github.com/matrix-org/matrix-js-sdk/pull/1598) * Fix typo in main readme [\#1597](https://github.com/matrix-org/matrix-js-sdk/pull/1597) * Remove rogue plus character [\#1596](https://github.com/matrix-org/matrix-js-sdk/pull/1596) * Fix call ID NaN [\#1595](https://github.com/matrix-org/matrix-js-sdk/pull/1595) * Fix Electron type merging [\#1594](https://github.com/matrix-org/matrix-js-sdk/pull/1594) * Fix browser screen share [\#1593](https://github.com/matrix-org/matrix-js-sdk/pull/1593) * Fix desktop Matrix screen sharing [\#1570](https://github.com/matrix-org/matrix-js-sdk/pull/1570) * Guard against confused server retry times [\#1591](https://github.com/matrix-org/matrix-js-sdk/pull/1591) * Decrypt redaction events [\#1589](https://github.com/matrix-org/matrix-js-sdk/pull/1589) * Fix edge cases with peeking where a room is re-peeked [\#1587](https://github.com/matrix-org/matrix-js-sdk/pull/1587) Changes in [9.6.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.6.0) (2021-02-03) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.6.0-rc.1...v9.6.0) * [Release] Fix edge cases with peeking where a room is re-peeked [\#1588](https://github.com/matrix-org/matrix-js-sdk/pull/1588) Changes in [9.6.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.6.0-rc.1) (2021-01-29) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.5.1...v9.6.0-rc.1) * Add support for getting call stats [\#1584](https://github.com/matrix-org/matrix-js-sdk/pull/1584) * Fix compatibility with v0 calls [\#1583](https://github.com/matrix-org/matrix-js-sdk/pull/1583) * Upgrade deps 2021-01 [\#1582](https://github.com/matrix-org/matrix-js-sdk/pull/1582) * Log the call ID when logging that we've received VoIP events [\#1581](https://github.com/matrix-org/matrix-js-sdk/pull/1581) * Fix extra negotiate message in Firefox [\#1579](https://github.com/matrix-org/matrix-js-sdk/pull/1579) * Add debug logs to encryption prep [\#1580](https://github.com/matrix-org/matrix-js-sdk/pull/1580) * Expose getPresence endpoint [\#1578](https://github.com/matrix-org/matrix-js-sdk/pull/1578) * Queue keys for backup even if backup isn't enabled yet [\#1577](https://github.com/matrix-org/matrix-js-sdk/pull/1577) * Stop retrying TURN access when forbidden [\#1576](https://github.com/matrix-org/matrix-js-sdk/pull/1576) * Add DTMF sending support [\#1573](https://github.com/matrix-org/matrix-js-sdk/pull/1573) Changes in [9.5.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.5.1) (2021-01-26) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.5.0...v9.5.1) * [Release] Fix compatibility with v0 calls [\#1585](https://github.com/matrix-org/matrix-js-sdk/pull/1585) Changes in [9.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.5.0) (2021-01-18) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.5.0-rc.1...v9.5.0) * No changes since rc.1 Changes in [9.5.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.5.0-rc.1) (2021-01-13) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.4.1...v9.5.0-rc.1) * Don't log if no WebRTC [\#1574](https://github.com/matrix-org/matrix-js-sdk/pull/1574) * Add _unstable_getSharedRooms [\#1417](https://github.com/matrix-org/matrix-js-sdk/pull/1417) * Bump node-notifier from 8.0.0 to 8.0.1 [\#1568](https://github.com/matrix-org/matrix-js-sdk/pull/1568) * Ignore party ID if opponent is v0 [\#1567](https://github.com/matrix-org/matrix-js-sdk/pull/1567) * Basic call transfer initiation support [\#1566](https://github.com/matrix-org/matrix-js-sdk/pull/1566) * Room version 6 is now a thing [\#1572](https://github.com/matrix-org/matrix-js-sdk/pull/1572) * Store keys with same index but better trust level [\#1571](https://github.com/matrix-org/matrix-js-sdk/pull/1571) * Use TypeScript source for development, swap to build during release [\#1561](https://github.com/matrix-org/matrix-js-sdk/pull/1561) * Revert "Ignore party ID if opponent is v0" [\#1565](https://github.com/matrix-org/matrix-js-sdk/pull/1565) * Basic call transfer initiation support [\#1558](https://github.com/matrix-org/matrix-js-sdk/pull/1558) * Ignore party ID if opponent is v0 [\#1559](https://github.com/matrix-org/matrix-js-sdk/pull/1559) * Honour a call reject event from another of our own devices [\#1562](https://github.com/matrix-org/matrix-js-sdk/pull/1562) Changes in [9.4.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.4.1) (2020-12-21) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.4.0...v9.4.1) * Further script tweaks to get all layers building again Changes in [9.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.4.0) (2020-12-21) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.4.0-rc.2...v9.4.0) * Revert `postinstall` script change, causes issues for other layers Changes in [9.4.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.4.0-rc.2) (2020-12-16) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.4.0-rc.1...v9.4.0-rc.2) * Remove `postinstall` script which also runs as a dependency [\#1560](https://github.com/matrix-org/matrix-js-sdk/pull/1560) Changes in [9.4.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.4.0-rc.1) (2020-12-16) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.3.0...v9.4.0-rc.1) * Fixes to support line 1 / 2 [\#1553](https://github.com/matrix-org/matrix-js-sdk/pull/1553) * Add API for listening to remote hold status, advertise VoIP V1 [\#1549](https://github.com/matrix-org/matrix-js-sdk/pull/1549) * A hangup from another client is still valid [\#1555](https://github.com/matrix-org/matrix-js-sdk/pull/1555) * Remove temporary build step for tests [\#1554](https://github.com/matrix-org/matrix-js-sdk/pull/1554) * Move browser build steps to prepublish only [\#1552](https://github.com/matrix-org/matrix-js-sdk/pull/1552) * Extend getSsoLoginUrl for MSC2858 [\#1541](https://github.com/matrix-org/matrix-js-sdk/pull/1541) Changes in [9.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.3.0) (2020-12-07) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.3.0-rc.1...v9.3.0) * No changes since rc.1 Changes in [9.3.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.3.0-rc.1) (2020-12-02) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.2.0...v9.3.0-rc.1) * Export CallError [\#1551](https://github.com/matrix-org/matrix-js-sdk/pull/1551) * Upgrade dependencies [\#1550](https://github.com/matrix-org/matrix-js-sdk/pull/1550) * Don't log error when environment does not support WebRTC [\#1547](https://github.com/matrix-org/matrix-js-sdk/pull/1547) * Fix dehydration method name [\#1544](https://github.com/matrix-org/matrix-js-sdk/pull/1544) Changes in [9.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.2.0) (2020-11-23) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.2.0-rc.1...v9.2.0) * [Release] Fix dehydration method name [\#1545](https://github.com/matrix-org/matrix-js-sdk/pull/1545) Changes in [9.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.2.0-rc.1) (2020-11-18) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.1.0...v9.2.0-rc.1) * Implement call holding functionality [\#1532](https://github.com/matrix-org/matrix-js-sdk/pull/1532) * Support awaitable one-time dehydration [\#1537](https://github.com/matrix-org/matrix-js-sdk/pull/1537) * Client set profile methods update own user [\#1534](https://github.com/matrix-org/matrix-js-sdk/pull/1534) Changes in [9.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.1.0) (2020-11-09) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.1.0-rc.1...v9.1.0) * No changes since rc.1 Changes in [9.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.1.0-rc.1) (2020-11-04) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.0.1...v9.1.0-rc.1) * Fix spelling error in the server ACL event type [\#1535](https://github.com/matrix-org/matrix-js-sdk/pull/1535) * await idb operations from crypto store for dehydration [\#1533](https://github.com/matrix-org/matrix-js-sdk/pull/1533) * Fix stuck never-sending messages [\#1531](https://github.com/matrix-org/matrix-js-sdk/pull/1531) * Await key cache check to avoid prompts [\#1529](https://github.com/matrix-org/matrix-js-sdk/pull/1529) * Improve ICE candidate batching [\#1524](https://github.com/matrix-org/matrix-js-sdk/pull/1524) * Convert logger to typescript [\#1527](https://github.com/matrix-org/matrix-js-sdk/pull/1527) * Fix logger typo [\#1525](https://github.com/matrix-org/matrix-js-sdk/pull/1525) * bind online listener to window instead of document [\#1523](https://github.com/matrix-org/matrix-js-sdk/pull/1523) * Support m.call.select_answer [\#1522](https://github.com/matrix-org/matrix-js-sdk/pull/1522) Changes in [9.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.0.1) (2020-10-28) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.0.0...v9.0.1) * [Release] Await key cache check to avoid prompts [\#1530](https://github.com/matrix-org/matrix-js-sdk/pull/1530) Changes in [9.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.0.0) (2020-10-26) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.0.0-rc.1...v9.0.0) * Fix logger typo [\#1528](https://github.com/matrix-org/matrix-js-sdk/pull/1528) Changes in [9.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.0.0-rc.1) (2020-10-21) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.5.0...v9.0.0-rc.1) BREAKING CHANGES --- * `hasPendingEvent` now returns false instead of throwing when pending ordering mode is not `detached` All changes --- * Don't cache failures when fetching /versions [\#1521](https://github.com/matrix-org/matrix-js-sdk/pull/1521) * Install deps first as part of release [\#1518](https://github.com/matrix-org/matrix-js-sdk/pull/1518) * [Breaking] Change hasPendingEvent to return false if pending ordering !detached [\#1517](https://github.com/matrix-org/matrix-js-sdk/pull/1517) * Skip editor prompts for merges [\#1519](https://github.com/matrix-org/matrix-js-sdk/pull/1519) * Convert call test to TypeScript [\#1516](https://github.com/matrix-org/matrix-js-sdk/pull/1516) * Support party_id [\#1512](https://github.com/matrix-org/matrix-js-sdk/pull/1512) * Support m.call.reject [\#1510](https://github.com/matrix-org/matrix-js-sdk/pull/1510) * Remove specbuild from .gitignore [\#1515](https://github.com/matrix-org/matrix-js-sdk/pull/1515) * Log the error when we failed to send candidates [\#1514](https://github.com/matrix-org/matrix-js-sdk/pull/1514) * Fixes for call state machine [\#1503](https://github.com/matrix-org/matrix-js-sdk/pull/1503) * Fix call event handler listener removing [\#1506](https://github.com/matrix-org/matrix-js-sdk/pull/1506) * Set the type of the call based on the tracks [\#1501](https://github.com/matrix-org/matrix-js-sdk/pull/1501) * Use new local timestamp for calls [\#1499](https://github.com/matrix-org/matrix-js-sdk/pull/1499) * Adjust types and APIs to match React SDK [\#1502](https://github.com/matrix-org/matrix-js-sdk/pull/1502) * Make an accurate version of 'age' for events [\#1495](https://github.com/matrix-org/matrix-js-sdk/pull/1495) * Make 'options' parameter optional [\#1498](https://github.com/matrix-org/matrix-js-sdk/pull/1498) * Create a giant event type enum [\#1497](https://github.com/matrix-org/matrix-js-sdk/pull/1497) * Convert call.js to Typescript & update WebRTC APIs (re-apply) [\#1494](https://github.com/matrix-org/matrix-js-sdk/pull/1494) Changes in [8.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.5.0) (2020-10-12) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.5.0-rc.1...v8.5.0) * No changes since rc.1 Changes in [8.5.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.5.0-rc.1) (2020-10-07) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.4.1...v8.5.0-rc.1) * Add support for olm fallback keys [\#1467](https://github.com/matrix-org/matrix-js-sdk/pull/1467) * Fix editing local echoes not updating them in real time [\#1492](https://github.com/matrix-org/matrix-js-sdk/pull/1492) * Fix re-emit of Event.replaced to be on client and not room [\#1491](https://github.com/matrix-org/matrix-js-sdk/pull/1491) * Add space to log line [\#1496](https://github.com/matrix-org/matrix-js-sdk/pull/1496) * Revert "Convert call.js to Typescript & update WebRTC APIs" [\#1493](https://github.com/matrix-org/matrix-js-sdk/pull/1493) * Convert call.js to Typescript & update WebRTC APIs [\#1487](https://github.com/matrix-org/matrix-js-sdk/pull/1487) * Dehydrate and rehydrate devices [\#1436](https://github.com/matrix-org/matrix-js-sdk/pull/1436) * Keep local device after processing device list sync [\#1490](https://github.com/matrix-org/matrix-js-sdk/pull/1490) * Enforce logger module via lint rules [\#1489](https://github.com/matrix-org/matrix-js-sdk/pull/1489) * Extend method redactEvent with reason [\#1462](https://github.com/matrix-org/matrix-js-sdk/pull/1462) * Catch exception from call event handler [\#1484](https://github.com/matrix-org/matrix-js-sdk/pull/1484) * Ignore invalid candidates [\#1483](https://github.com/matrix-org/matrix-js-sdk/pull/1483) * Always push docs if they are generated [\#1478](https://github.com/matrix-org/matrix-js-sdk/pull/1478) * Only sign key backup with cross-signing keys when available [\#1481](https://github.com/matrix-org/matrix-js-sdk/pull/1481) * Upgrade dependencies [\#1479](https://github.com/matrix-org/matrix-js-sdk/pull/1479) Changes in [8.4.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.4.1) (2020-09-28) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.4.0...v8.4.1) * Catch exception from call event handler [\#1486](https://github.com/matrix-org/matrix-js-sdk/pull/1486) * Ignore invalid candidates [\#1485](https://github.com/matrix-org/matrix-js-sdk/pull/1485) Changes in [8.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.4.0) (2020-09-28) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.4.0-rc.1...v8.4.0) * Only sign key backup with cross-signing keys when available [\#1482](https://github.com/matrix-org/matrix-js-sdk/pull/1482) Changes in [8.4.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.4.0-rc.1) (2020-09-23) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.3.0...v8.4.0-rc.1) * If there are extraParams set, ensure that queryParams is defined [\#1477](https://github.com/matrix-org/matrix-js-sdk/pull/1477) * Add diagnostics to security bootstrap paths [\#1475](https://github.com/matrix-org/matrix-js-sdk/pull/1475) * Switch to a combination of better-docs and docdash [\#1459](https://github.com/matrix-org/matrix-js-sdk/pull/1459) * Undo attempts to cache private keys aggressively [\#1474](https://github.com/matrix-org/matrix-js-sdk/pull/1474) * Repair secret storage reset, cache keys when missing [\#1472](https://github.com/matrix-org/matrix-js-sdk/pull/1472) * Prevent parallel getVersions calls [\#1471](https://github.com/matrix-org/matrix-js-sdk/pull/1471) * Send end-of-candidates [\#1473](https://github.com/matrix-org/matrix-js-sdk/pull/1473) * Add a function for checking the /versions flag for forced e2ee [\#1470](https://github.com/matrix-org/matrix-js-sdk/pull/1470) * Add option to allow users of pantialaimon to use the SDK [\#1469](https://github.com/matrix-org/matrix-js-sdk/pull/1469) * Fixed Yarn broken link [\#1468](https://github.com/matrix-org/matrix-js-sdk/pull/1468) * some TypeScript and doc fixes [\#1466](https://github.com/matrix-org/matrix-js-sdk/pull/1466) * Remove Travis CI reference [\#1464](https://github.com/matrix-org/matrix-js-sdk/pull/1464) * Inject identity server token for 3pid invites on createRoom [\#1463](https://github.com/matrix-org/matrix-js-sdk/pull/1463) Changes in [8.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.3.0) (2020-09-14) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.3.0-rc.1...v8.3.0) * No changes since rc.1 Changes in [8.3.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.3.0-rc.1) (2020-09-09) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.2.0...v8.3.0-rc.1) * Add missing options in ICreateClientOpts [\#1452](https://github.com/matrix-org/matrix-js-sdk/pull/1452) * Ensure ready functions return boolean values [\#1457](https://github.com/matrix-org/matrix-js-sdk/pull/1457) * Handle missing cross-signing keys gracefully [\#1456](https://github.com/matrix-org/matrix-js-sdk/pull/1456) * Fix eslint ts override tsx matching [\#1451](https://github.com/matrix-org/matrix-js-sdk/pull/1451) * Untangle cross-signing and secret storage [\#1450](https://github.com/matrix-org/matrix-js-sdk/pull/1450) Changes in [8.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.2.0) (2020-09-01) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.2.0-rc.1...v8.2.0) ## Security notice JS SDK 8.2.0 fixes an issue where encrypted state events could break incoming call handling. Thanks to @awesome-michael from Awesome Technologies for responsibly disclosing this via Matrix's Security Disclosure Policy. ## All changes * No changes since rc.1 Changes in [8.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.2.0-rc.1) (2020-08-26) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.1.0...v8.2.0-rc.1) * Add state event check [\#1449](https://github.com/matrix-org/matrix-js-sdk/pull/1449) * Add method to check whether client .well-known has been fetched [\#1444](https://github.com/matrix-org/matrix-js-sdk/pull/1444) * Handle auth errors during cross-signing key upload [\#1443](https://github.com/matrix-org/matrix-js-sdk/pull/1443) * Don't fail if the requested audio output isn't available [\#1448](https://github.com/matrix-org/matrix-js-sdk/pull/1448) * Fix logging failures [\#1447](https://github.com/matrix-org/matrix-js-sdk/pull/1447) * Log the constraints we pass to getUserMedia [\#1446](https://github.com/matrix-org/matrix-js-sdk/pull/1446) * Use SAS emoji data from matrix-doc [\#1440](https://github.com/matrix-org/matrix-js-sdk/pull/1440) Changes in [8.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.1.0) (2020-08-17) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.1.0-rc.1...v8.1.0) * No changes since rc.1 Changes in [8.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.1.0-rc.1) (2020-08-13) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.0.1...v8.1.0-rc.1) * Update on Promises [\#1438](https://github.com/matrix-org/matrix-js-sdk/pull/1438) * Store and request master cross-signing key [\#1437](https://github.com/matrix-org/matrix-js-sdk/pull/1437) * Filter out non-string display names [\#1433](https://github.com/matrix-org/matrix-js-sdk/pull/1433) * Bump elliptic from 6.5.2 to 6.5.3 [\#1427](https://github.com/matrix-org/matrix-js-sdk/pull/1427) * Replace Riot with Element in docs and comments [\#1431](https://github.com/matrix-org/matrix-js-sdk/pull/1431) * Remove leftover bits of TSLint [\#1430](https://github.com/matrix-org/matrix-js-sdk/pull/1430) Changes in [8.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.0.1) (2020-08-05) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.0.1-rc.1...v8.0.1) * Filter out non-string display names [\#1434](https://github.com/matrix-org/matrix-js-sdk/pull/1434) Changes in [8.0.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.0.1-rc.1) (2020-07-31) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v8.0.0...v8.0.1-rc.1) * Remove redundant lint dependencies [\#1426](https://github.com/matrix-org/matrix-js-sdk/pull/1426) * Upload all keys when we start using a new key backup version [\#1428](https://github.com/matrix-org/matrix-js-sdk/pull/1428) * Expose countSessionsNeedingBackup [\#1429](https://github.com/matrix-org/matrix-js-sdk/pull/1429) * Configure and use new eslint package [\#1422](https://github.com/matrix-org/matrix-js-sdk/pull/1422) Changes in [8.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v8.0.0) (2020-07-27) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v7.1.0...v8.0.0) BREAKING CHANGES --- * `RoomState` events changed to use a Map instead of an object, which changes the collection APIs available to access them. All Changes --- * Properly support txnId [\#1424](https://github.com/matrix-org/matrix-js-sdk/pull/1424) * [BREAKING] Remove deprecated getIdenticonUri [\#1423](https://github.com/matrix-org/matrix-js-sdk/pull/1423) * Bump lodash from 4.17.15 to 4.17.19 [\#1421](https://github.com/matrix-org/matrix-js-sdk/pull/1421) * [BREAKING] Convert RoomState's stored state map to a real map [\#1419](https://github.com/matrix-org/matrix-js-sdk/pull/1419) Changes in [7.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v7.1.0) (2020-07-03) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v7.1.0-rc.1...v7.1.0) * No changes since rc.1 Changes in [7.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v7.1.0-rc.1) (2020-07-01) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v7.0.0...v7.1.0-rc.1) * Ask general crypto callbacks for 4S privkey if operation adapter doesn't have it yet [\#1414](https://github.com/matrix-org/matrix-js-sdk/pull/1414) * Fix ICreateClientOpts missing idBaseUrl [\#1413](https://github.com/matrix-org/matrix-js-sdk/pull/1413) * Increase max event listeners for rooms [\#1411](https://github.com/matrix-org/matrix-js-sdk/pull/1411) * Don't trust keys megolm received from backup for verifying the sender [\#1406](https://github.com/matrix-org/matrix-js-sdk/pull/1406) * Raise the last known account data / state event for an update [\#1410](https://github.com/matrix-org/matrix-js-sdk/pull/1410) * Isolate encryption bootstrap side-effects [\#1380](https://github.com/matrix-org/matrix-js-sdk/pull/1380) * Add method to get current in-flight to-device requests [\#1405](https://github.com/matrix-org/matrix-js-sdk/pull/1405) Changes in [7.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v7.0.0) (2020-06-23) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v7.0.0-rc.1...v7.0.0) * No changes since rc.1 Changes in [7.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v7.0.0-rc.1) (2020-06-17) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v6.2.2...v7.0.0-rc.1) BREAKING CHANGES --- * Presence lists were removed from the spec in r0.5.0, and the corresponding methods have now been removed here as well: * `getPresenceList` * `inviteToPresenceList` * `dropFromPresenceList` All changes --- * Remove support for unspecced device-specific push rules [\#1404](https://github.com/matrix-org/matrix-js-sdk/pull/1404) * Use existing session id for fetching flows as to not get a new session [\#1403](https://github.com/matrix-org/matrix-js-sdk/pull/1403) * Upgrade deps [\#1400](https://github.com/matrix-org/matrix-js-sdk/pull/1400) * Bring back backup key format migration [\#1398](https://github.com/matrix-org/matrix-js-sdk/pull/1398) * Fix: more informative error message when we cant find a key to decrypt with [\#1313](https://github.com/matrix-org/matrix-js-sdk/pull/1313) * Add js-sdk mechanism for polling client well-known for config [\#1394](https://github.com/matrix-org/matrix-js-sdk/pull/1394) * Fix verification request timeouts to match spec [\#1388](https://github.com/matrix-org/matrix-js-sdk/pull/1388) * Drop presence list methods [\#1391](https://github.com/matrix-org/matrix-js-sdk/pull/1391) * Batch up URL previews to prevent excessive requests [\#1395](https://github.com/matrix-org/matrix-js-sdk/pull/1395) Changes in [6.2.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v6.2.2) (2020-06-16) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v6.2.1...v6.2.2) * Use existing session id for fetching flows as to not get a new session [\#1407](https://github.com/matrix-org/matrix-js-sdk/pull/1407) Changes in [6.2.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v6.2.1) (2020-06-05) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v6.2.0...v6.2.1) * Bring back backup key format migration [\#1399](https://github.com/matrix-org/matrix-js-sdk/pull/1399) Changes in [6.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v6.2.0) (2020-06-04) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v6.2.0-rc.1...v6.2.0) * No changes since rc.1 Changes in [6.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v6.2.0-rc.1) (2020-06-02) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v6.1.0...v6.2.0-rc.1) * Make auth argument in the register request compliant with r0.6.0 [\#1304](https://github.com/matrix-org/matrix-js-sdk/pull/1304) * Send the wrong auth params with the right auth params [\#1393](https://github.com/matrix-org/matrix-js-sdk/pull/1393) * encrypt cached keys with pickle key [\#1387](https://github.com/matrix-org/matrix-js-sdk/pull/1387) * Fix replying to key share requests [\#1385](https://github.com/matrix-org/matrix-js-sdk/pull/1385) * Add dist to package.json files so CDNs can serve it [\#1384](https://github.com/matrix-org/matrix-js-sdk/pull/1384) * Fix getVersion warning saying undefined room [\#1382](https://github.com/matrix-org/matrix-js-sdk/pull/1382) * Combine the two places we processed client-level default push rules [\#1379](https://github.com/matrix-org/matrix-js-sdk/pull/1379) * make MAC check robust against unpadded vs padded base64 differences [\#1378](https://github.com/matrix-org/matrix-js-sdk/pull/1378) * Remove key backup format migration [\#1375](https://github.com/matrix-org/matrix-js-sdk/pull/1375) * Add simple browserify browser-matrix.js tests [\#1241](https://github.com/matrix-org/matrix-js-sdk/pull/1241) * support new key agreement method for SAS [\#1376](https://github.com/matrix-org/matrix-js-sdk/pull/1376) Changes in [6.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v6.1.0) (2020-05-19) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v6.1.0-rc.1...v6.1.0) * No changes since rc.1 Changes in [6.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v6.1.0-rc.1) (2020-05-14) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v6.0.0...v6.1.0-rc.1) * Remove support for asymmetric 4S encryption [\#1373](https://github.com/matrix-org/matrix-js-sdk/pull/1373) * Increase timeout for 2nd phase of Olm session creation [\#1367](https://github.com/matrix-org/matrix-js-sdk/pull/1367) * Add logging on decryption retries [\#1366](https://github.com/matrix-org/matrix-js-sdk/pull/1366) * Emit event when a trusted self-key is stored [\#1364](https://github.com/matrix-org/matrix-js-sdk/pull/1364) * Customize error payload for oversized messages [\#1352](https://github.com/matrix-org/matrix-js-sdk/pull/1352) * Return null for key backup state when we haven't checked yet [\#1363](https://github.com/matrix-org/matrix-js-sdk/pull/1363) * Added a progressCallback for backup key loading [\#1351](https://github.com/matrix-org/matrix-js-sdk/pull/1351) * Add initialFetch param to willUpdateDevices / devicesUpdated [\#1360](https://github.com/matrix-org/matrix-js-sdk/pull/1360) * Fix race between sending .request and receiving .ready over to_device [\#1359](https://github.com/matrix-org/matrix-js-sdk/pull/1359) * Handle race between sending and await next event from other party [\#1357](https://github.com/matrix-org/matrix-js-sdk/pull/1357) * Add crypto.willUpdateDevices event and make getStoredDevices/getStoredDevicesForUser synchronous [\#1354](https://github.com/matrix-org/matrix-js-sdk/pull/1354) * Fix sender of local echo events in unsigned redactions [\#1350](https://github.com/matrix-org/matrix-js-sdk/pull/1350) * Remove redundant key backup setup path [\#1353](https://github.com/matrix-org/matrix-js-sdk/pull/1353) * Remove some dead code from _retryDecryption [\#1349](https://github.com/matrix-org/matrix-js-sdk/pull/1349) * Don't send key requests until after sync processing is finished [\#1348](https://github.com/matrix-org/matrix-js-sdk/pull/1348) * Prevent attempts to send olm messages to ourselves [\#1346](https://github.com/matrix-org/matrix-js-sdk/pull/1346) * Retry account data upload requests [\#1345](https://github.com/matrix-org/matrix-js-sdk/pull/1345) * Log first known index with megolm session updates [\#1344](https://github.com/matrix-org/matrix-js-sdk/pull/1344) * Prune to_device messages to avoid sending empty messages [\#1343](https://github.com/matrix-org/matrix-js-sdk/pull/1343) * Convert bunch of things to TypeScript [\#1335](https://github.com/matrix-org/matrix-js-sdk/pull/1335) * Add logging when making new Olm sessions [\#1342](https://github.com/matrix-org/matrix-js-sdk/pull/1342) * Fix: handle filter not found [\#1340](https://github.com/matrix-org/matrix-js-sdk/pull/1340) * Make getAccountDataFromServer return null if not found [\#1338](https://github.com/matrix-org/matrix-js-sdk/pull/1338) * Fix setDefaultKeyId to fail if the request fails [\#1336](https://github.com/matrix-org/matrix-js-sdk/pull/1336) * Document setRoomEncryption not modifying room state [\#1328](https://github.com/matrix-org/matrix-js-sdk/pull/1328) * Fix: don't do extra /filter request when enabling lazy loading of members [\#1332](https://github.com/matrix-org/matrix-js-sdk/pull/1332) * Reject attemptAuth promise if no auth flow found [\#1329](https://github.com/matrix-org/matrix-js-sdk/pull/1329) * Fix FilterComponent allowed_values check [\#1327](https://github.com/matrix-org/matrix-js-sdk/pull/1327) * Serialise Olm prekey decryptions [\#1326](https://github.com/matrix-org/matrix-js-sdk/pull/1326) * Fix: crash when backup key needs fixing from corruption issue [\#1324](https://github.com/matrix-org/matrix-js-sdk/pull/1324) * Fix cross-signing/SSSS reset [\#1322](https://github.com/matrix-org/matrix-js-sdk/pull/1322) * Implement QR code reciprocate for self-verification with untrusted MSK [\#1320](https://github.com/matrix-org/matrix-js-sdk/pull/1320) Changes in [6.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v6.0.0) (2020-05-05) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v6.0.0-rc.2...v6.0.0) * Add progress callback for key backups [\#1368](https://github.com/matrix-org/matrix-js-sdk/pull/1368) Changes in [6.0.0-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v6.0.0-rc.2) (2020-05-01) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v6.0.0-rc.1...v6.0.0-rc.2) * Emit event when a trusted self-key is stored [\#1365](https://github.com/matrix-org/matrix-js-sdk/pull/1365) Changes in [6.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v6.0.0-rc.1) (2020-04-30) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.3.1-rc.4...v6.0.0-rc.1) BREAKING CHANGES --- * client.getStoredDevicesForUser and client.getStoredDevices are no longer async All Changes --- * Add initialFetch param to willUpdateDevices / devicesUpdated [\#1362](https://github.com/matrix-org/matrix-js-sdk/pull/1362) * Fix race between sending .request and receiving .ready over to_device [\#1361](https://github.com/matrix-org/matrix-js-sdk/pull/1361) * Handle race between sending and await next event from other party [\#1358](https://github.com/matrix-org/matrix-js-sdk/pull/1358) * Add crypto.willUpdateDevices event and make getStoredDevices/getStoredDevicesForUser synchronous [\#1356](https://github.com/matrix-org/matrix-js-sdk/pull/1356) * Remove redundant key backup setup path [\#1355](https://github.com/matrix-org/matrix-js-sdk/pull/1355) Changes in [5.3.1-rc.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.3.1-rc.4) (2020-04-23) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.3.1-rc.3...v5.3.1-rc.4) * Retry account data upload requests [\#1347](https://github.com/matrix-org/matrix-js-sdk/pull/1347) * Fix: handle filter not found [\#1341](https://github.com/matrix-org/matrix-js-sdk/pull/1341) * Make getAccountDataFromServer return null if not found [\#1339](https://github.com/matrix-org/matrix-js-sdk/pull/1339) * Fix setDefaultKeyId to fail if the request fails [\#1337](https://github.com/matrix-org/matrix-js-sdk/pull/1337) * Fix: don't do extra /filter request when enabling lazy loading of members [\#1333](https://github.com/matrix-org/matrix-js-sdk/pull/1333) * Reject attemptAuth promise if no auth flow found [\#1331](https://github.com/matrix-org/matrix-js-sdk/pull/1331) * Serialise Olm prekey decryptions [\#1330](https://github.com/matrix-org/matrix-js-sdk/pull/1330) Changes in [5.3.1-rc.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.3.1-rc.3) (2020-04-17) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.3.1-rc.2...v5.3.1-rc.3) * Fix cross-signing/SSSS reset [\#1323](https://github.com/matrix-org/matrix-js-sdk/pull/1323) * Fix: crash when backup key needs fixing from corruption issue [\#1325](https://github.com/matrix-org/matrix-js-sdk/pull/1325) Changes in [5.3.1-rc.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.3.1-rc.2) (2020-04-16) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.3.1-rc.1...v5.3.1-rc.2) * Implement QR code reciprocate for self-verification with untrusted MSK [\#1321](https://github.com/matrix-org/matrix-js-sdk/pull/1321) Changes in [5.3.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.3.1-rc.1) (2020-04-15) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.3.0-rc.1...v5.3.1-rc.1) * Adapt release script for riot-desktop [\#1319](https://github.com/matrix-org/matrix-js-sdk/pull/1319) * Fix: prevent spurious notifications from indexer [\#1318](https://github.com/matrix-org/matrix-js-sdk/pull/1318) * Always create our own user object [\#1317](https://github.com/matrix-org/matrix-js-sdk/pull/1317) * Fix incorrect backup key format in SSSS [\#1311](https://github.com/matrix-org/matrix-js-sdk/pull/1311) * Fix e2ee crash after refreshing after having received a cross-singing key reset [\#1315](https://github.com/matrix-org/matrix-js-sdk/pull/1315) * Fix: catch send errors in SAS verifier [\#1314](https://github.com/matrix-org/matrix-js-sdk/pull/1314) * Clear cross-signing keys when detecting the keys have changed [\#1312](https://github.com/matrix-org/matrix-js-sdk/pull/1312) * Upgrade deps [\#1310](https://github.com/matrix-org/matrix-js-sdk/pull/1310) Changes in [5.3.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.3.0-rc.1) (2020-04-08) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.2.0...v5.3.0-rc.1) * Store key backup key in cache as Uint8Array [\#1308](https://github.com/matrix-org/matrix-js-sdk/pull/1308) * Use the correct request body for the /keys/query endpoint. [\#1307](https://github.com/matrix-org/matrix-js-sdk/pull/1307) * Avoid creating two devices on registration [\#1305](https://github.com/matrix-org/matrix-js-sdk/pull/1305) * Lower max-warnings to 81 [\#1306](https://github.com/matrix-org/matrix-js-sdk/pull/1306) * Move key backup key creation before caching [\#1303](https://github.com/matrix-org/matrix-js-sdk/pull/1303) * Expose function to force-reset outgoing room key requests [\#1298](https://github.com/matrix-org/matrix-js-sdk/pull/1298) * Add isSelfVerification property to VerificationRequest [\#1302](https://github.com/matrix-org/matrix-js-sdk/pull/1302) * QR code reciprocation [\#1297](https://github.com/matrix-org/matrix-js-sdk/pull/1297) * Add ability to check symmetric SSSS key before we try to use it [\#1294](https://github.com/matrix-org/matrix-js-sdk/pull/1294) * Add some debug logging for events stuck to bottom of timeline [\#1296](https://github.com/matrix-org/matrix-js-sdk/pull/1296) * Fix: spontanous verification request cancellation under some circumstances [\#1295](https://github.com/matrix-org/matrix-js-sdk/pull/1295) * Receive private key for caching from the app layer [\#1293](https://github.com/matrix-org/matrix-js-sdk/pull/1293) * Track whether we have verified a user before [\#1292](https://github.com/matrix-org/matrix-js-sdk/pull/1292) * Fix: error during tests [\#1222](https://github.com/matrix-org/matrix-js-sdk/pull/1222) * Send .done event for to_device verification [\#1288](https://github.com/matrix-org/matrix-js-sdk/pull/1288) * Request the key backup key & restore backup [\#1291](https://github.com/matrix-org/matrix-js-sdk/pull/1291) * Make screen sharing works on Chrome using getDisplayMedia() [\#1276](https://github.com/matrix-org/matrix-js-sdk/pull/1276) * Fix isVerified returning false [\#1289](https://github.com/matrix-org/matrix-js-sdk/pull/1289) * Fix: verification gets cancelled when event gets duplicated [\#1286](https://github.com/matrix-org/matrix-js-sdk/pull/1286) * Use requestSecret on the client to request secrets [\#1287](https://github.com/matrix-org/matrix-js-sdk/pull/1287) * Allow guests to fetch TURN servers [\#1277](https://github.com/matrix-org/matrix-js-sdk/pull/1277) Changes in [5.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.2.0) (2020-03-30) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.2.0-rc.1...v5.2.0) * Fix isVerified returning false [\#1290](https://github.com/matrix-org/matrix-js-sdk/pull/1290) Changes in [5.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.2.0-rc.1) (2020-03-26) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.1.1...v5.2.0-rc.1) * Add a flag for whether cross signing signatures are trusted [\#1285](https://github.com/matrix-org/matrix-js-sdk/pull/1285) * Cache user and self signing keys during bootstrap [\#1282](https://github.com/matrix-org/matrix-js-sdk/pull/1282) * remove unnecessary promise [\#1283](https://github.com/matrix-org/matrix-js-sdk/pull/1283) * Functions to cache session backups key automatically [\#1281](https://github.com/matrix-org/matrix-js-sdk/pull/1281) * Add function for checking cross-signing is ready [\#1279](https://github.com/matrix-org/matrix-js-sdk/pull/1279) * Use symmetric encryption for SSSS [\#1228](https://github.com/matrix-org/matrix-js-sdk/pull/1228) * Migrate SSSS to use symmetric algorithm [\#1238](https://github.com/matrix-org/matrix-js-sdk/pull/1238) * Migration to symmetric SSSS [\#1272](https://github.com/matrix-org/matrix-js-sdk/pull/1272) * Reduce number of one-time-key requests [\#1280](https://github.com/matrix-org/matrix-js-sdk/pull/1280) * Fix: assume the requested method is supported by other party with to_device [\#1275](https://github.com/matrix-org/matrix-js-sdk/pull/1275) * Use checkDeviceTrust when computing untrusted devices [\#1278](https://github.com/matrix-org/matrix-js-sdk/pull/1278) * Add a store for backup keys [\#1271](https://github.com/matrix-org/matrix-js-sdk/pull/1271) * Upload only new device signature of master key [\#1268](https://github.com/matrix-org/matrix-js-sdk/pull/1268) * Expose prepareToEncrypt in the client API [\#1270](https://github.com/matrix-org/matrix-js-sdk/pull/1270) * Don't kill the whole device download if one device gives an error [\#1269](https://github.com/matrix-org/matrix-js-sdk/pull/1269) * Handle racing .start event during self verification [\#1267](https://github.com/matrix-org/matrix-js-sdk/pull/1267) * A crypto.keySignatureUploadFailure event reported the wrong source [\#1266](https://github.com/matrix-org/matrix-js-sdk/pull/1266) * Fix editing of unsent messages by waiting for actual event id [\#1263](https://github.com/matrix-org/matrix-js-sdk/pull/1263) * Fix: ensureOlmSessionsForDevices parameter format [\#1264](https://github.com/matrix-org/matrix-js-sdk/pull/1264) * Remove stuff that yarn install doesn't think we need [\#1261](https://github.com/matrix-org/matrix-js-sdk/pull/1261) * Fix: prevent error being thrown during sync in some cases [\#1258](https://github.com/matrix-org/matrix-js-sdk/pull/1258) * Force `is_verified` for key backups to bool and fix computation [\#1259](https://github.com/matrix-org/matrix-js-sdk/pull/1259) * Add a method for legacy single device verification, returning a verification request [\#1257](https://github.com/matrix-org/matrix-js-sdk/pull/1257) * yarn upgrade [\#1256](https://github.com/matrix-org/matrix-js-sdk/pull/1256) Changes in [5.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.1.1) (2020-03-17) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.1.1-rc.1...v5.1.1) * Fix: ensureOlmSessionsForDevices parameter format [\#1265](https://github.com/matrix-org/matrix-js-sdk/pull/1265) * Fix: prevent error being thrown during sync in some cases [\#1262](https://github.com/matrix-org/matrix-js-sdk/pull/1262) * Force `is_verified` for key backups to bool and fix computation [\#1260](https://github.com/matrix-org/matrix-js-sdk/pull/1260) Changes in [5.1.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.1.1-rc.1) (2020-03-11) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.1.0...v5.1.1-rc.1) * refactor megolm encryption to improve perceived speed [\#1252](https://github.com/matrix-org/matrix-js-sdk/pull/1252) * Remove v1 identity server fallbacks [\#1253](https://github.com/matrix-org/matrix-js-sdk/pull/1253) * Use alt_aliases instead of local ones for room names [\#1251](https://github.com/matrix-org/matrix-js-sdk/pull/1251) * Upload cross-signing key signatures in the background [\#1250](https://github.com/matrix-org/matrix-js-sdk/pull/1250) * Fix secret sharing names to match spec [\#1249](https://github.com/matrix-org/matrix-js-sdk/pull/1249) * Cleanup: remove crypto.verification.start event [\#1248](https://github.com/matrix-org/matrix-js-sdk/pull/1248) * Fix regression in key backup request params [\#1246](https://github.com/matrix-org/matrix-js-sdk/pull/1246) * Use cross-signing trust to mark backups verified [\#1244](https://github.com/matrix-org/matrix-js-sdk/pull/1244) * Check both cross-signing and local trust for key sharing [\#1243](https://github.com/matrix-org/matrix-js-sdk/pull/1243) * Fixed up tests to match new way that crypto stores are created [\#1242](https://github.com/matrix-org/matrix-js-sdk/pull/1242) * Store USK and SSK locally [\#1235](https://github.com/matrix-org/matrix-js-sdk/pull/1235) * Use unpadded base64 for QR code secrets [\#1236](https://github.com/matrix-org/matrix-js-sdk/pull/1236) * Don't require .done event for finishing self-verification [\#1239](https://github.com/matrix-org/matrix-js-sdk/pull/1239) * Don't cancel as 3rd party in verification request [\#1237](https://github.com/matrix-org/matrix-js-sdk/pull/1237) * Verification: log when switching start event [\#1234](https://github.com/matrix-org/matrix-js-sdk/pull/1234) * Perform crypto store operations directly after transaction [\#1233](https://github.com/matrix-org/matrix-js-sdk/pull/1233) * More verification request logging [\#1232](https://github.com/matrix-org/matrix-js-sdk/pull/1232) * Upgrade deps [\#1231](https://github.com/matrix-org/matrix-js-sdk/pull/1231) Changes in [5.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.1.0) (2020-03-02) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.1.0-rc.1...v5.1.0) * No changes since rc.1 Changes in [5.1.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.1.0-rc.1) (2020-02-26) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.0.1...v5.1.0-rc.1) * Add latest dist-tag for releases [\#1230](https://github.com/matrix-org/matrix-js-sdk/pull/1230) * Add room method for alt_aliases [\#1225](https://github.com/matrix-org/matrix-js-sdk/pull/1225) * Remove buildkite pipeline [\#1227](https://github.com/matrix-org/matrix-js-sdk/pull/1227) * don't assume verify has been called when receiving a cancellation in verifier [\#1226](https://github.com/matrix-org/matrix-js-sdk/pull/1226) * Reduce secret size for new binary packing [\#1221](https://github.com/matrix-org/matrix-js-sdk/pull/1221) * misc rageshake fixes [\#1223](https://github.com/matrix-org/matrix-js-sdk/pull/1223) * Fix cancelled historical requests not appearing as cancelled [\#1220](https://github.com/matrix-org/matrix-js-sdk/pull/1220) * Fix renaming error that broke QR code verification [\#1217](https://github.com/matrix-org/matrix-js-sdk/pull/1217) Changes in [5.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.0.1) (2020-02-19) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.0.0...v5.0.1) * add method for new /aliases endpoint [\#1219](https://github.com/matrix-org/matrix-js-sdk/pull/1219) * method for checking if other party supports verification method [\#1213](https://github.com/matrix-org/matrix-js-sdk/pull/1213) * add local echo state for accepting or declining a verif req [\#1210](https://github.com/matrix-org/matrix-js-sdk/pull/1210) * make logging compatible with rageshakes [\#1214](https://github.com/matrix-org/matrix-js-sdk/pull/1214) * Find existing requests when starting a new verification request [\#1209](https://github.com/matrix-org/matrix-js-sdk/pull/1209) * log MAC calculation during SAS [\#1211](https://github.com/matrix-org/matrix-js-sdk/pull/1211) Changes in [5.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.0.0) (2020-02-17) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v5.0.0-rc.1...v5.0.0) * No changes since rc.1 Changes in [5.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v5.0.0-rc.1) (2020-02-13) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v4.0.0...v5.0.0-rc.1) BREAKING CHANGES --- * The verification methods API has removed an argument ([\#1206](https://github.com/matrix-org/matrix-js-sdk/pull/1206)) All Changes --- * Remove methods argument to verification [\#1206](https://github.com/matrix-org/matrix-js-sdk/pull/1206) * don't do a dynamic import of request [\#1207](https://github.com/matrix-org/matrix-js-sdk/pull/1207) * QR self-verification fixes [\#1201](https://github.com/matrix-org/matrix-js-sdk/pull/1201) * Log every verification event [\#1204](https://github.com/matrix-org/matrix-js-sdk/pull/1204) * dont require .done event from other party [\#1203](https://github.com/matrix-org/matrix-js-sdk/pull/1203) * New option to fully reset Secret Storage keys in boostrapSecretStorage [\#1202](https://github.com/matrix-org/matrix-js-sdk/pull/1202) * Add function to estimate target device for a VerificationRequest [\#1190](https://github.com/matrix-org/matrix-js-sdk/pull/1190) * pass ssss item name to callback so we can differentiate UI on it [\#1200](https://github.com/matrix-org/matrix-js-sdk/pull/1200) * add export/import of Olm devices [\#1167](https://github.com/matrix-org/matrix-js-sdk/pull/1167) * Convert utils.js -> utils.ts [\#1199](https://github.com/matrix-org/matrix-js-sdk/pull/1199) * Don't sign ourselves as a user [\#1197](https://github.com/matrix-org/matrix-js-sdk/pull/1197) * Add a bunch of logging to verification [\#1196](https://github.com/matrix-org/matrix-js-sdk/pull/1196) * Fix: always return a valid string from InRoomChannel.getEventType [\#1198](https://github.com/matrix-org/matrix-js-sdk/pull/1198) * add logging when a request is being cancelled [\#1195](https://github.com/matrix-org/matrix-js-sdk/pull/1195) * Don't explode verification validation if we don't have an event type [\#1194](https://github.com/matrix-org/matrix-js-sdk/pull/1194) * Fix: verification request appearing for users that are not the receiver or sender if they are in room [\#1193](https://github.com/matrix-org/matrix-js-sdk/pull/1193) * Fix getting secrets encoded with passthrough keys [\#1192](https://github.com/matrix-org/matrix-js-sdk/pull/1192) * Update QR code handling for new spec [\#1175](https://github.com/matrix-org/matrix-js-sdk/pull/1175) * Don't add ephemeral events to timeline when peeking [\#1188](https://github.com/matrix-org/matrix-js-sdk/pull/1188) * Fix typo [\#1189](https://github.com/matrix-org/matrix-js-sdk/pull/1189) * Verification: resolve race between .start events from both parties [\#1187](https://github.com/matrix-org/matrix-js-sdk/pull/1187) * Add option to bootstrap to start new key backup [\#1184](https://github.com/matrix-org/matrix-js-sdk/pull/1184) * Add a bunch of null guards to feature checks [\#1182](https://github.com/matrix-org/matrix-js-sdk/pull/1182) * docs: fix MatrixClient reference [\#1183](https://github.com/matrix-org/matrix-js-sdk/pull/1183) * Add helper to obtain the cancellation code for a verification request [\#1180](https://github.com/matrix-org/matrix-js-sdk/pull/1180) * Publish pre-releases as a separate tag on npm [\#1178](https://github.com/matrix-org/matrix-js-sdk/pull/1178) * Fix support for passthrough keys [\#1177](https://github.com/matrix-org/matrix-js-sdk/pull/1177) * Trust our own cross-signing keys if we verify them with another device [\#1174](https://github.com/matrix-org/matrix-js-sdk/pull/1174) * Ensure cross-signing keys are downloaded when checking trust [\#1176](https://github.com/matrix-org/matrix-js-sdk/pull/1176) * Don't log verification validation errors for normal messages [\#1172](https://github.com/matrix-org/matrix-js-sdk/pull/1172) * Fix bootstrap cleanup [\#1173](https://github.com/matrix-org/matrix-js-sdk/pull/1173) * QR code verification [\#1155](https://github.com/matrix-org/matrix-js-sdk/pull/1155) * expose deviceId prop on device channel [\#1171](https://github.com/matrix-org/matrix-js-sdk/pull/1171) * Move & upgrade babel runtime into dependencies (like it wants) [\#1169](https://github.com/matrix-org/matrix-js-sdk/pull/1169) * Add unit tests for verifying your own device, remove .event property on verification request [\#1166](https://github.com/matrix-org/matrix-js-sdk/pull/1166) * For dm-verification, also consider events sent by other devices of same user as "our" events [\#1163](https://github.com/matrix-org/matrix-js-sdk/pull/1163) * Add a prepare script [\#1161](https://github.com/matrix-org/matrix-js-sdk/pull/1161) * Remove :deviceId from /keys/upload/:deviceId as not spec-compliant [\#1162](https://github.com/matrix-org/matrix-js-sdk/pull/1162) * Refactor and expose some logic publicly for the TimelineWindow class. [\#1159](https://github.com/matrix-org/matrix-js-sdk/pull/1159) * Allow a device key upload request without auth [\#1158](https://github.com/matrix-org/matrix-js-sdk/pull/1158) * Support for .ready verification event (MSC2366) & other things [\#1140](https://github.com/matrix-org/matrix-js-sdk/pull/1140) Changes in [4.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v4.0.0) (2020-01-27) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v4.0.0-rc.1...v4.0.0) * Move & upgrade babel runtime into dependencies (like it wants) [\#1170](https://github.com/matrix-org/matrix-js-sdk/pull/1170) * Add a prepare script [\#1164](https://github.com/matrix-org/matrix-js-sdk/pull/1164) Changes in [4.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v4.0.0-rc.1) (2020-01-20) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v3.0.0...v4.0.0-rc.1) BREAKING CHANGES ================ * The js-sdk node module now exports ES6 rather than ES5. If you wish to supports target that aren't compatible with ES6, you will need to transpile the js-sdk to a suitable dialect. All Changes =========== * Convert secret storage to new account data API [\#1154](https://github.com/matrix-org/matrix-js-sdk/pull/1154) * Add v5 as a safe room version [\#1157](https://github.com/matrix-org/matrix-js-sdk/pull/1157) * Add API to get account data from server [\#1153](https://github.com/matrix-org/matrix-js-sdk/pull/1153) * Fix sourcemaps by refactoring the build system [\#1151](https://github.com/matrix-org/matrix-js-sdk/pull/1151) * record, report, and notify about olm errors [\#1146](https://github.com/matrix-org/matrix-js-sdk/pull/1146) * Send device messages for the same user in same API call. [\#1148](https://github.com/matrix-org/matrix-js-sdk/pull/1148) * Add an option to ignore unverified devices [\#1150](https://github.com/matrix-org/matrix-js-sdk/pull/1150) * Sign key backup with cross-signing key on upgrade [\#1144](https://github.com/matrix-org/matrix-js-sdk/pull/1144) * Emoji verification: Change name of 🔒 to lock [\#1145](https://github.com/matrix-org/matrix-js-sdk/pull/1145) * use a separate object for each encrypted content [\#1147](https://github.com/matrix-org/matrix-js-sdk/pull/1147) * Sourcemaps: develop -> feature branch [\#1143](https://github.com/matrix-org/matrix-js-sdk/pull/1143) * Use a safer import/export scheme for the ContentRepo utilities [\#1134](https://github.com/matrix-org/matrix-js-sdk/pull/1134) * Fix error handling in decryptGroupMessage [\#1142](https://github.com/matrix-org/matrix-js-sdk/pull/1142) * Add additional properties to package.json for riot-web's webpack [\#1131](https://github.com/matrix-org/matrix-js-sdk/pull/1131) * Fix import for indexeddb crypto store [\#1133](https://github.com/matrix-org/matrix-js-sdk/pull/1133) * Use the right request when creating clients [\#1132](https://github.com/matrix-org/matrix-js-sdk/pull/1132) * Target NodeJS 10, minified browser bundle, and other publishing/package things [\#1127](https://github.com/matrix-org/matrix-js-sdk/pull/1127) * Re-focus sourcemap generation [\#1126](https://github.com/matrix-org/matrix-js-sdk/pull/1126) * Remove ancient polyfill for prototype inheritance [\#1125](https://github.com/matrix-org/matrix-js-sdk/pull/1125) * Remove "source-map-support" from tests because it makes sourcemaps worse [\#1124](https://github.com/matrix-org/matrix-js-sdk/pull/1124) * Remove ancient "use strict" annotations [\#1123](https://github.com/matrix-org/matrix-js-sdk/pull/1123) * Use ES6 imports/exports instead of older CommonJS ones [\#1122](https://github.com/matrix-org/matrix-js-sdk/pull/1122) * [BREAKING] Refactor the entire build process [\#1113](https://github.com/matrix-org/matrix-js-sdk/pull/1113) Changes in [3.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v3.0.0) (2020-01-13) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v3.0.0-rc.1...v3.0.0) * No changes from rc.1 Changes in [3.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v3.0.0-rc.1) (2020-01-06) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.6...v3.0.0-rc.1) BREAKING CHANGES ================ * matrix-js-sdk no longer uses bluebird promises, so promises returned by the js-sdk no longer support the done() method. Code that calls done() on promises returned by the js-sdk will break and will need to be updated to remove the done() call. All Changes =========== * Make displayName disambiguation more fuzzy especially against RTL/LTR content [\#1141](https://github.com/matrix-org/matrix-js-sdk/pull/1141) * stop trying to resend event if we get M_TOO_LARGE [\#1129](https://github.com/matrix-org/matrix-js-sdk/pull/1129) * Fix creating a key backup with cross signing diabled [\#1139](https://github.com/matrix-org/matrix-js-sdk/pull/1139) * Use checkDeviceTrust with key backup [\#1138](https://github.com/matrix-org/matrix-js-sdk/pull/1138) * Add support for passthrough SSSS secrets [\#1128](https://github.com/matrix-org/matrix-js-sdk/pull/1128) * Add support for key backups using secret storage [\#1118](https://github.com/matrix-org/matrix-js-sdk/pull/1118) * Remove unused user verification event [\#1117](https://github.com/matrix-org/matrix-js-sdk/pull/1117) * Fix check for private keys [\#1116](https://github.com/matrix-org/matrix-js-sdk/pull/1116) * Restore watching mode for `start:watch` [\#1115](https://github.com/matrix-org/matrix-js-sdk/pull/1115) * Add secret storage bootstrap flow [\#1079](https://github.com/matrix-org/matrix-js-sdk/pull/1079) * Part 1 of many: Upgrade to babel@7 and TypeScript [\#1112](https://github.com/matrix-org/matrix-js-sdk/pull/1112) * Remove Bluebird: phase 2.5 [\#1100](https://github.com/matrix-org/matrix-js-sdk/pull/1100) * Remove Bluebird: phase 3 [\#1088](https://github.com/matrix-org/matrix-js-sdk/pull/1088) * ignore m.key.verification.done messages when we don't expect any more messages [\#1104](https://github.com/matrix-org/matrix-js-sdk/pull/1104) * dont cancel on remote echo of own .request event [\#1111](https://github.com/matrix-org/matrix-js-sdk/pull/1111) * Refactor verification request code [\#1109](https://github.com/matrix-org/matrix-js-sdk/pull/1109) * Fix device list's cross-signing storage path [\#1105](https://github.com/matrix-org/matrix-js-sdk/pull/1105) * yarn upgrade [\#1103](https://github.com/matrix-org/matrix-js-sdk/pull/1103) Changes in [2.4.6](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.6) (2019-12-09) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.6-rc.1...v2.4.6) * No changes since rc.1 Changes in [2.4.6-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.6-rc.1) (2019-12-04) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.5...v2.4.6-rc.1) * Update alias handling [\#1102](https://github.com/matrix-org/matrix-js-sdk/pull/1102) * increase timeout on flush to fix failing unit test [\#1096](https://github.com/matrix-org/matrix-js-sdk/pull/1096) * Disable broken cross-signing test [\#1095](https://github.com/matrix-org/matrix-js-sdk/pull/1095) * Fix a couple SAS tests [\#1094](https://github.com/matrix-org/matrix-js-sdk/pull/1094) * Fix Olm unwedging test [\#1093](https://github.com/matrix-org/matrix-js-sdk/pull/1093) * Fix empty string handling in push notifications [\#1089](https://github.com/matrix-org/matrix-js-sdk/pull/1089) * expand e2ee logging to better debug UISIs [\#1090](https://github.com/matrix-org/matrix-js-sdk/pull/1090) * Remove Bluebird: phase 2 [\#1087](https://github.com/matrix-org/matrix-js-sdk/pull/1087) * Relax identity server discovery checks to FAIL_PROMPT [\#1062](https://github.com/matrix-org/matrix-js-sdk/pull/1062) * Fix incorrect return value of MatrixClient.prototype.uploadKeys [\#1061](https://github.com/matrix-org/matrix-js-sdk/pull/1061) * Fix calls in e2e rooms [\#1086](https://github.com/matrix-org/matrix-js-sdk/pull/1086) * Monitor verification request over DM as well [\#1085](https://github.com/matrix-org/matrix-js-sdk/pull/1085) * Remove 'check' npm script [\#1084](https://github.com/matrix-org/matrix-js-sdk/pull/1084) * Always process call events in batches [\#1083](https://github.com/matrix-org/matrix-js-sdk/pull/1083) * Fix ringing chirp on loading [\#1082](https://github.com/matrix-org/matrix-js-sdk/pull/1082) * Remove *most* bluebird specific things [\#1081](https://github.com/matrix-org/matrix-js-sdk/pull/1081) * Switch to Jest [\#1080](https://github.com/matrix-org/matrix-js-sdk/pull/1080) Changes in [2.4.5](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.5) (2019-11-27) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.4...v2.4.5) * Relax identity server discovery checks to FAIL_PROMPT * Expand E2EE debug logging to diagnose "unable to decrypt" errors Changes in [2.4.4](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.4) (2019-11-25) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.4-rc.1...v2.4.4) * No changes since rc.1 Changes in [2.4.4-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.4-rc.1) (2019-11-20) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.3...v2.4.4-rc.1) * Fix SAS verification in encrypted DMs [\#1077](https://github.com/matrix-org/matrix-js-sdk/pull/1077) * Cross-signing / secret storage tweaks [\#1078](https://github.com/matrix-org/matrix-js-sdk/pull/1078) * Fix local trust for key backups [\#1075](https://github.com/matrix-org/matrix-js-sdk/pull/1075) * Add method to get last active timestamp in room [\#1072](https://github.com/matrix-org/matrix-js-sdk/pull/1072) * Check the right Synapse endpoint for determining admin capabilities [\#1071](https://github.com/matrix-org/matrix-js-sdk/pull/1071) * Cross Signing Support [\#832](https://github.com/matrix-org/matrix-js-sdk/pull/832) * Don't double cancel verification request [\#1064](https://github.com/matrix-org/matrix-js-sdk/pull/1064) * Support for verification requests in the timeline [\#1067](https://github.com/matrix-org/matrix-js-sdk/pull/1067) * Use stable API prefix for 3PID APIs when supported [\#1066](https://github.com/matrix-org/matrix-js-sdk/pull/1066) * Remove Jenkins scripts [\#1063](https://github.com/matrix-org/matrix-js-sdk/pull/1063) Changes in [2.4.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.3) (2019-11-04) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.3-rc.1...v2.4.3) * No changes since rc.1 Changes in [2.4.3-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.3-rc.1) (2019-10-30) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.2...v2.4.3-rc.1) * fix the path in references to logger.js [\#1056](https://github.com/matrix-org/matrix-js-sdk/pull/1056) * verification in DMs [\#1050](https://github.com/matrix-org/matrix-js-sdk/pull/1050) * Properly documented the function possible returns [\#1054](https://github.com/matrix-org/matrix-js-sdk/pull/1054) * Downgrade to Bluebird 3.5.5 to fix Firefox [\#1055](https://github.com/matrix-org/matrix-js-sdk/pull/1055) * Upgrade safe deps to latest major version [\#1053](https://github.com/matrix-org/matrix-js-sdk/pull/1053) * Don't include .js in the import string. [\#1052](https://github.com/matrix-org/matrix-js-sdk/pull/1052) Changes in [2.4.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.2) (2019-10-18) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.2-rc.1...v2.4.2) * No changes since v2.4.2-rc.1 Changes in [2.4.2-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.2-rc.1) (2019-10-09) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.1...v2.4.2-rc.1) * Log state of Olm sessions [\#1047](https://github.com/matrix-org/matrix-js-sdk/pull/1047) * Add method to get access to all timelines [\#1048](https://github.com/matrix-org/matrix-js-sdk/pull/1048) Changes in [2.4.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.1) (2019-10-01) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.0...v2.4.1) * Upgrade deps [\#1046](https://github.com/matrix-org/matrix-js-sdk/pull/1046) * Ignore crypto events with no content [\#1043](https://github.com/matrix-org/matrix-js-sdk/pull/1043) Changes in [2.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.0) (2019-09-27) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.4.0-rc.1...v2.4.0) * Clean Yarn cache during release [\#1045](https://github.com/matrix-org/matrix-js-sdk/pull/1045) Changes in [2.4.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.4.0-rc.1) (2019-09-25) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.3.2...v2.4.0-rc.1) * Remove id_server from creds for interactive auth [\#1044](https://github.com/matrix-org/matrix-js-sdk/pull/1044) * Remove IS details from requestToken to HS [\#1041](https://github.com/matrix-org/matrix-js-sdk/pull/1041) * Add support for sending MSISDN tokens to alternate URLs [\#1040](https://github.com/matrix-org/matrix-js-sdk/pull/1040) * Add separate 3PID add and bind APIs [\#1038](https://github.com/matrix-org/matrix-js-sdk/pull/1038) * Bump eslint-utils from 1.4.0 to 1.4.2 [\#1037](https://github.com/matrix-org/matrix-js-sdk/pull/1037) * Handle WebRTC security errors as non-fatal [\#1036](https://github.com/matrix-org/matrix-js-sdk/pull/1036) * Check for r0.6.0 support in addition to unstable feature flags [\#1035](https://github.com/matrix-org/matrix-js-sdk/pull/1035) * Update room members on member event redaction [\#1030](https://github.com/matrix-org/matrix-js-sdk/pull/1030) * Support hidden read receipts [\#1028](https://github.com/matrix-org/matrix-js-sdk/pull/1028) * Do 3pid lookups in lowercase [\#1029](https://github.com/matrix-org/matrix-js-sdk/pull/1029) * Add Synapse admin functions for deactivating a user [\#1027](https://github.com/matrix-org/matrix-js-sdk/pull/1027) * Fix addPendingEvent with pending event order == chronological [\#1026](https://github.com/matrix-org/matrix-js-sdk/pull/1026) * Add AutoDiscovery.getRawClientConfig() for easy .well-known lookups [\#1024](https://github.com/matrix-org/matrix-js-sdk/pull/1024) * Don't convert errors to JSON if they are JSON already [\#1025](https://github.com/matrix-org/matrix-js-sdk/pull/1025) * Send id_access_token to HS for use in proxied IS requests [\#1022](https://github.com/matrix-org/matrix-js-sdk/pull/1022) * Clean up JSON handling in identity server requests [\#1023](https://github.com/matrix-org/matrix-js-sdk/pull/1023) * Use the v2 (hashed) lookup for identity server queries [\#1021](https://github.com/matrix-org/matrix-js-sdk/pull/1021) * Add getIdServer() & doesServerRequireIdServerParam() [\#1018](https://github.com/matrix-org/matrix-js-sdk/pull/1018) * Make requestToken endpoints work without ID Server [\#1019](https://github.com/matrix-org/matrix-js-sdk/pull/1019) * Fix setIdentityServer [\#1016](https://github.com/matrix-org/matrix-js-sdk/pull/1016) * Change ICE fallback server and make fallback opt-in [\#1015](https://github.com/matrix-org/matrix-js-sdk/pull/1015) * Throw an exception if trying to do an ID server request with no ID server [\#1014](https://github.com/matrix-org/matrix-js-sdk/pull/1014) * Add setIdentityServerUrl [\#1013](https://github.com/matrix-org/matrix-js-sdk/pull/1013) * Add matrix base API to report an event [\#1011](https://github.com/matrix-org/matrix-js-sdk/pull/1011) * Fix POST body for v2 IS requests [\#1010](https://github.com/matrix-org/matrix-js-sdk/pull/1010) * Add API for bulk lookup on the Identity Server [\#1009](https://github.com/matrix-org/matrix-js-sdk/pull/1009) * Remove deprecated authedRequestWithPrefix and requestWithPrefix [\#1000](https://github.com/matrix-org/matrix-js-sdk/pull/1000) * Add API for checking IS account info [\#1007](https://github.com/matrix-org/matrix-js-sdk/pull/1007) * Support rewriting push rules when our internal defaults change [\#1006](https://github.com/matrix-org/matrix-js-sdk/pull/1006) * Upgrade dependencies [\#1005](https://github.com/matrix-org/matrix-js-sdk/pull/1005) Changes in [2.3.2](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.3.2) (2019-09-16) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.3.2-rc.1...v2.3.2) * [Release] Fix addPendingEvent with pending event order == chronological [\#1034](https://github.com/matrix-org/matrix-js-sdk/pull/1034) Changes in [2.3.2-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.3.2-rc.1) (2019-09-13) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.3.1...v2.3.2-rc.1) * Synapse admin functions to release [\#1033](https://github.com/matrix-org/matrix-js-sdk/pull/1033) * [To Release] Add matrix base API to report an event [\#1032](https://github.com/matrix-org/matrix-js-sdk/pull/1032) Changes in [2.3.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.3.1) (2019-09-12) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.3.1-rc.1...v2.3.1) * No changes since rc.1 Changes in [2.3.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.3.1-rc.1) (2019-09-11) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.3.0...v2.3.1-rc.1) * Update room members on member event redaction [\#1031](https://github.com/matrix-org/matrix-js-sdk/pull/1031) Changes in [2.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.3.0) (2019-08-05) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.3.0-rc.1...v2.3.0) * [release] Support rewriting push rules when our internal defaults change [\#1008](https://github.com/matrix-org/matrix-js-sdk/pull/1008) Changes in [2.3.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v2.3.0-rc.1) (2019-07-31) ========================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v2.2.0...v2.3.0-rc.1) * Add support for IS v2 API with authentication [\#1002](https://github.com/matrix-org/matrix-js-sdk/pull/1002) * Tombstone bugfixes [\#1001](https://github.com/matrix-org/matrix-js-sdk/pull/1001) * Support for MSC2140 (terms of service for IS/IM) [\#988](https://github.com/matrix-org/matrix-js-sdk/pull/988) * Add a request method to /devices [\#994](https://github.com/matrix-org/matrix-js-sdk/pull/994) 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. matrix-js-sdk-9.11.0/CONTRIBUTING.rst000066400000000000000000000137501403504662500167530ustar00rootroot00000000000000Contributing 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 continuous integration, and all pull requests get automatically tested: 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 js-sdk aims to target TypeScript/ES6. All new files should be written in TypeScript and existing files should use ES6 principles where possible. Members should not be exported as a default export in general - it causes problems with the architecture of the SDK (index file becomes less clear) and could introduce naming problems (as default exports get aliased upon import). In general, avoid using `export default`. The remaining 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 matrix-js-sdk-9.11.0/LICENSE000066400000000000000000000236761403504662500153270ustar00rootroot00000000000000 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 matrix-js-sdk-9.11.0/README.md000066400000000000000000000254521403504662500155730ustar00rootroot00000000000000Matrix 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 the browser 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:
matrix-js-sdk-9.11.0/examples/browser/lib/000077500000000000000000000000001403504662500203535ustar00rootroot00000000000000matrix-js-sdk-9.11.0/examples/browser/lib/matrix.js000077700000000000000000000000001403504662500273372../../../dist/browser-matrix.jsustar00rootroot00000000000000matrix-js-sdk-9.11.0/examples/crypto-browser/000077500000000000000000000000001403504662500211235ustar00rootroot00000000000000matrix-js-sdk-9.11.0/examples/crypto-browser/lib/000077500000000000000000000000001403504662500216715ustar00rootroot00000000000000matrix-js-sdk-9.11.0/examples/crypto-browser/lib/.gitignore000066400000000000000000000000171403504662500236570ustar00rootroot00000000000000olm.js olm.wasmmatrix-js-sdk-9.11.0/examples/crypto-browser/lib/matrix.js000077700000000000000000000000001403504662500306552../../../dist/browser-matrix.jsustar00rootroot00000000000000matrix-js-sdk-9.11.0/examples/crypto-browser/olm-device-export-import.html000066400000000000000000000046001403504662500266640ustar00rootroot00000000000000 Test Crypto in Browser

Testing export/import of Olm devices in the browser

  • Make sure you built the current version of the Matrix JS SDK (yarn build)
  • copy olm.js and olm.wasm from a recent release of Olm (was tested with version 3.1.4) in directory lib/
  • start a local Matrix homeserver (on port 8008, or change the port in the code)
  • Serve this HTML file (e.g. python3 -m http.server) and go to it through your browser
  • in the JS console, do:
    aliceMatrixClient = await newMatrixClient("alice-"+randomHex());
    await aliceMatrixClient.exportDevice();
    await aliceMatrixClient.getAccessToken();
                
  • copy the result of exportDevice and getAccessToken somewhere (not in a JS variable as it will be destroyed when you refresh the page)
  • refresh the page (F5) to make sure the client is destroyed
  • Do the following, replacing ALICE_ID with the user ID of Alice (you can find it in the exported data)
    bobMatrixClient = await newMatrixClient("bob-"+randomHex());
    roomId = await bobMatrixClient.createEncryptedRoom([ALICE_ID]);
    await bobMatrixClient.sendTextMessage('Hi Alice!', roomId);
                
  • Again, refresh the page (F5). You may want to clear your console as well.
  • Now do the following, using the exported data and the access token you saved previously:
    aliceMatrixClient = await importMatrixClient(EXPORTED_DATA, ACCESS_TOKEN);
                
  • You should see the message sent by Bob printed in the console.
matrix-js-sdk-9.11.0/examples/crypto-browser/olm-device-export-import.js000066400000000000000000000076531403504662500263470ustar00rootroot00000000000000if (!Olm) { console.error( "global.Olm does not seem to be present." + " Did you forget to add olm in the lib/ directory?" ); } const BASE_URL = 'http://localhost:8008'; const ROOM_CRYPTO_CONFIG = { algorithm: 'm.megolm.v1.aes-sha2' }; const PASSWORD = 'password'; // useful to create new usernames window.randomHex = () => Math.floor(Math.random() * (10**6)).toString(16); window.newMatrixClient = async function (username) { const registrationClient = matrixcs.createClient(BASE_URL); const userRegisterResult = await registrationClient.register( username, PASSWORD, null, { type: 'm.login.dummy' } ); const matrixClient = matrixcs.createClient({ baseUrl: BASE_URL, userId: userRegisterResult.user_id, accessToken: userRegisterResult.access_token, deviceId: userRegisterResult.device_id, sessionStore: new matrixcs.WebStorageSessionStore(window.localStorage), cryptoStore: new matrixcs.MemoryCryptoStore(), }); extendMatrixClient(matrixClient); await matrixClient.initCrypto(); await matrixClient.startClient(); return matrixClient; } window.importMatrixClient = async function (exportedDevice, accessToken) { const matrixClient = matrixcs.createClient({ baseUrl: BASE_URL, deviceToImport: exportedDevice, accessToken, sessionStore: new matrixcs.WebStorageSessionStore(window.localStorage), cryptoStore: new matrixcs.MemoryCryptoStore(), }); extendMatrixClient(matrixClient); await matrixClient.initCrypto(); await matrixClient.startClient(); return matrixClient; } function extendMatrixClient(matrixClient) { // automatic join matrixClient.on('RoomMember.membership', async (event, member) => { if (member.membership === 'invite' && member.userId === matrixClient.getUserId()) { await matrixClient.joinRoom(member.roomId); // setting up of room encryption seems to be triggered automatically // but if we don't wait for it the first messages we send are unencrypted await matrixClient.setRoomEncryption(member.roomId, { algorithm: 'm.megolm.v1.aes-sha2' }) } }); matrixClient.onDecryptedMessage = message => { console.log('Got encrypted message: ', message); } matrixClient.on('Event.decrypted', (event) => { if (event.getType() === 'm.room.message'){ matrixClient.onDecryptedMessage(event.getContent().body); } else { console.log('decrypted an event of type', event.getType()); console.log(event); } }); matrixClient.createEncryptedRoom = async function(usersToInvite) { const { room_id: roomId, } = await this.createRoom({ visibility: 'private', invite: usersToInvite, }); // matrixClient.setRoomEncryption() only updates local state // but does not send anything to the server // (see https://github.com/matrix-org/matrix-js-sdk/issues/905) // so we do it ourselves with 'sendStateEvent' await this.sendStateEvent( roomId, 'm.room.encryption', ROOM_CRYPTO_CONFIG, ); await this.setRoomEncryption( roomId, ROOM_CRYPTO_CONFIG, ); // Marking all devices as verified let room = this.getRoom(roomId); let members = (await room.getEncryptionTargetMembers()).map(x => x["userId"]) let memberkeys = await this.downloadKeys(members); for (const userId in memberkeys) { for (const deviceId in memberkeys[userId]) { await this.setDeviceVerified(userId, deviceId); } } return roomId; } matrixClient.sendTextMessage = async function(message, roomId) { return matrixClient.sendMessage( roomId, { body: message, msgtype: 'm.text', } ) } }matrix-js-sdk-9.11.0/examples/node/000077500000000000000000000000001403504662500170475ustar00rootroot00000000000000matrix-js-sdk-9.11.0/examples/node/README.md000066400000000000000000000025451403504662500203340ustar00rootroot00000000000000This 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) ``` matrix-js-sdk-9.11.0/examples/node/app.js000066400000000000000000000307131403504662500201710ustar00rootroot00000000000000var 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).then(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).then(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).then(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 }).then(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).then(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 eventMap = 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("-")); eventMap.keys().forEach(function(eventType) { if (eventType === "m.room.member") { return; } // use /members instead. var eventEventMap = eventMap.get(eventType); eventEventMap.keys().forEach(function(stateKey) { var typeAndKey = eventType + ( stateKey.length > 0 ? "("+stateKey+")" : "" ); var typeStr = fixWidth(typeAndKey, eTypeHeader.length); var event = eventEventMap.get(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. matrix-js-sdk-9.11.0/examples/node/package.json000066400000000000000000000003631403504662500213370ustar00rootroot00000000000000{ "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" } } matrix-js-sdk-9.11.0/examples/voip/000077500000000000000000000000001403504662500170775ustar00rootroot00000000000000matrix-js-sdk-9.11.0/examples/voip/README.md000066400000000000000000000003031403504662500203520ustar00rootroot00000000000000To 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``. matrix-js-sdk-9.11.0/examples/voip/browserTest.js000066400000000000000000000055761403504662500217750ustar00rootroot00000000000000console.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(); matrix-js-sdk-9.11.0/examples/voip/index.html000066400000000000000000000013221403504662500210720ustar00rootroot00000000000000 VoIP Test You can place and receive calls with this example. Make sure to edit the constants in browserTest.js first.
matrix-js-sdk-9.11.0/examples/voip/lib/000077500000000000000000000000001403504662500176455ustar00rootroot00000000000000matrix-js-sdk-9.11.0/examples/voip/lib/matrix.js000077700000000000000000000000001403504662500266312../../../dist/browser-matrix.jsustar00rootroot00000000000000matrix-js-sdk-9.11.0/git-hooks/000077500000000000000000000000001403504662500162105ustar00rootroot00000000000000matrix-js-sdk-9.11.0/git-hooks/pre-commit000077500000000000000000000007461403504662500202210ustar00rootroot00000000000000#!/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 matrix-js-sdk-9.11.0/jsdoc.json000066400000000000000000000010401403504662500162740ustar00rootroot00000000000000{ "tags": { "allowUnknownTags": true }, "plugins": [ "node_modules/better-docs/category", "node_modules/better-docs/typescript" ], "source": { "include": [ "src" ], "includePattern": ".(ts|js)$" }, "opts": { "encoding": "utf8", "destination": ".jsdoc", "readme": "README.md", "recurse": true, "verbose": true, "template": "node_modules/docdash" }, "docdash": { "static": true, "private": false, "search": true, "collapse": true, "typedefs": true } } matrix-js-sdk-9.11.0/package.json000066400000000000000000000070601403504662500165750ustar00rootroot00000000000000{ "name": "matrix-js-sdk", "version": "9.11.0", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", "start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"", "dist": "echo 'This is for the release script so it can make assets (browser bundle).' && yarn build", "clean": "rimraf lib dist", "build": "yarn build:dev && yarn build:compile-browser && yarn build:minify-browser", "build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types", "build:types": "tsc --emitDeclarationOnly", "build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src", "build:compile-browser": "mkdirp dist && browserify -d src/browser-index.js -p [ tsify -p ./tsconfig.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js", "build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js", "gendoc": "jsdoc -c jsdoc.json -P package.json", "lint": "yarn lint:types && yarn lint:js", "lint:js": "eslint --max-warnings 72 src spec", "lint:types": "tsc --noEmit", "test": "jest spec/ --coverage --testEnvironment node", "test:watch": "jest spec/ --coverage --testEnvironment node --watch" }, "repository": { "type": "git", "url": "https://github.com/matrix-org/matrix-js-sdk" }, "keywords": [ "matrix-org" ], "main": "./lib/index.js", "browser": "./lib/browser-index.js", "matrix_src_main": "./src/index.ts", "matrix_src_browser": "./src/browser-index.js", "matrix_lib_main": "./lib/index.js", "matrix_lib_typings": "./lib/index.d.ts", "author": "matrix.org", "license": "Apache-2.0", "files": [ "dist", "lib", "src", "git-revision.txt", "CHANGELOG.md", "CONTRIBUTING.rst", "LICENSE", "README.md", "package.json", "release.sh" ], "dependencies": { "@babel/runtime": "^7.12.5", "another-json": "^0.2.0", "browser-request": "^0.3.3", "bs58": "^4.0.1", "content-type": "^1.0.4", "loglevel": "^1.7.1", "qs": "^6.9.6", "request": "^2.88.2", "unhomoglyph": "^1.0.6" }, "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/plugin-proposal-class-properties": "^7.12.1", "@babel/plugin-proposal-numeric-separator": "^7.12.7", "@babel/plugin-proposal-object-rest-spread": "^7.12.1", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-transform-runtime": "^7.12.10", "@babel/preset-env": "^7.12.11", "@babel/preset-typescript": "^7.12.7", "@babel/register": "^7.12.10", "@types/jest": "^26.0.20", "@types/node": "12", "@types/request": "^2.48.5", "babel-eslint": "^10.1.0", "babel-jest": "^26.6.3", "babelify": "^10.0.0", "better-docs": "^2.3.2", "browserify": "^17.0.0", "docdash": "^1.2.0", "eslint": "7.18.0", "eslint-config-matrix-org": "^0.2.0", "eslint-plugin-babel": "^5.3.1", "exorcist": "^1.0.1", "fake-indexeddb": "^3.1.2", "jest": "^26.6.3", "jest-localstorage-mock": "^2.4.6", "jsdoc": "^3.6.6", "matrix-mock-request": "^1.2.3", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "rimraf": "^3.0.2", "terser": "^5.5.1", "tsify": "^5.0.2", "typescript": "^4.1.3" }, "jest": { "testEnvironment": "node" }, "typings": "./lib/index.d.ts" } matrix-js-sdk-9.11.0/release.sh000077500000000000000000000314301403504662500162640ustar00rootroot00000000000000#!/bin/bash # # Script to perform a release of matrix-js-sdk and downstream projects. # # 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/) # # Note: this script is also used to release matrix-react-sdk and element-web. 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 # We use Git branch / commit dependencies for some packages, and Yarn seems # to have a hard time getting that right. See also # https://github.com/yarnpkg/yarn/issues/4734. As a workaround, we clean the # global cache here to ensure we get the right thing. yarn cache clean # Ensure all dependencies are updated yarn install --ignore-scripts 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. if [ -z "$skip_npm" ]; then 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 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" # For the published and dist versions of the package, we copy the # `matrix_lib_main` and `matrix_lib_typings` fields to `main` and `typings` (if # they exist). This small bit of gymnastics allows us to use the TypeScript # source directly for development without needing to build before linting or # testing. for i in main typings do lib_value=$(jq -r ".matrix_lib_$i" package.json) if [ "$lib_value" != "null" ]; then jq ".$i = .matrix_lib_$i" package.json > package.json.new && mv package.json.new package.json fi done # 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. # Tag both releases and prereleases as `next` so the last stable release remains # the default. if [ -z "$skip_npm" ]; then npm publish --tag next if [ $prerelease -eq 0 ]; then # For a release, also add the default `latest` tag. package=$(cat package.json | jq -er .name) npm dist-tag add "$package@$release" latest fi fi 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" git push origin gh-pages 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" --no-edit # push master to github git push origin master # finally, merge master back onto develop (if it exists) if [ $(git branch -lr | grep origin/develop -c) -ge 1 ]; then git checkout develop git pull git merge master --no-edit # When merging to develop, we need revert the `main` and `typings` fields if # we adjusted them previously. for i in main typings do # If a `lib` prefixed value is present, it means we adjusted the field # earlier at publish time, so we should revert it now. if [ "$(jq -r ".matrix_lib_$i" package.json)" != "null" ]; then # If there's a `src` prefixed value, use that, otherwise delete. # This is used to delete the `typings` field and reset `main` back # to the TypeScript source. src_value=$(jq -r ".matrix_src_$i" package.json) if [ "$src_value" != "null" ]; then jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json else jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json fi fi done if [ -n "$(git ls-files --modified package.json)" ]; then echo "Committing develop package.json" git commit package.json -m "Resetting package fields for development" fi git push origin develop fi matrix-js-sdk-9.11.0/scripts/000077500000000000000000000000001403504662500157735ustar00rootroot00000000000000matrix-js-sdk-9.11.0/scripts/changelog_head.py000077500000000000000000000006131403504662500212600ustar00rootroot00000000000000#!/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 matrix-js-sdk-9.11.0/spec/000077500000000000000000000000001403504662500152365ustar00rootroot00000000000000matrix-js-sdk-9.11.0/spec/MockStorageApi.js000066400000000000000000000026061403504662500204500ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * A mock implementation of the webstorage api * @constructor */ export 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; }, }; matrix-js-sdk-9.11.0/spec/TestClient.js000066400000000000000000000166501403504662500176620ustar00rootroot00000000000000/* 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. */ // load olm before the sdk if possible import './olm-loader'; import MockHttpBackend from 'matrix-mock-request'; import {LocalStorageCryptoStore} from '../src/crypto/store/localStorage-crypto-store'; import {logger} from '../src/logger'; import {WebStorageSessionStore} from "../src/store/session/webstorage"; import {syncPromise} from "./test-utils"; import {createClient} from "../src/matrix"; import {MockStorageApi} from "./MockStorageApi"; /** * 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 function TestClient( userId, deviceId, accessToken, sessionStoreBackend, options, ) { this.userId = userId; this.deviceId = deviceId; if (sessionStoreBackend === undefined) { sessionStoreBackend = new MockStorageApi(); } const sessionStore = new 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 = createClient(options); this.deviceKeys = null; this.oneTimeKeys = {}; this._callEventHandler = { calls: new Map(), }; } 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(), 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).not.toEqual({}); 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), syncPromise(this.client), ]).then(() => { logger.log(`${this}: flushSync completed`); }); }; TestClient.prototype.isFallbackICEServerAllowed = function() { return true; }; matrix-js-sdk-9.11.0/spec/browserify/000077500000000000000000000000001403504662500174315ustar00rootroot00000000000000matrix-js-sdk-9.11.0/spec/browserify/setupTests.js000066400000000000000000000013641403504662500221560ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // stub for browser-matrix browserify tests global.XMLHttpRequest = jest.fn(); afterAll(() => { // clean up XMLHttpRequest mock global.XMLHttpRequest = undefined; }); matrix-js-sdk-9.11.0/spec/browserify/sync-browserify.spec.js000066400000000000000000000063121403504662500240670ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // load XmlHttpRequest mock import "./setupTests"; import "../../dist/browser-matrix"; // uses browser-matrix instead of the src import {MockStorageApi} from "../MockStorageApi"; import {WebStorageSessionStore} from "../../src/store/session/webstorage"; import MockHttpBackend from "matrix-mock-request"; import {LocalStorageCryptoStore} from "../../src/crypto/store/localStorage-crypto-store"; import * as utils from "../test-utils"; const USER_ID = "@user:test.server"; const DEVICE_ID = "device_id"; const ACCESS_TOKEN = "access_token"; const ROOM_ID = "!room_id:server.test"; /* global matrixcs */ describe("Browserify Test", function() { let client; let httpBackend; async function createTestClient() { const sessionStoreBackend = new MockStorageApi(); const sessionStore = new WebStorageSessionStore(sessionStoreBackend); const httpBackend = new MockHttpBackend(); const options = { baseUrl: "http://" + USER_ID + ".test.server", userId: USER_ID, accessToken: ACCESS_TOKEN, deviceId: DEVICE_ID, sessionStore: sessionStore, request: httpBackend.requestFn, cryptoStore: new LocalStorageCryptoStore(sessionStoreBackend), }; const client = matrixcs.createClient(options); httpBackend.when("GET", "/pushrules").respond(200, {}); httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); return { client, httpBackend }; } beforeEach(async () => { ({client, httpBackend} = await createTestClient()); await client.startClient(); }); afterEach(async () => { client.stopClient(); await httpBackend.stop(); }); it("Sync", async function() { const event = utils.mkMembership({ room: ROOM_ID, mship: "join", user: "@other_user:server.test", name: "Displayname", }); const syncData = { next_batch: "batch1", rooms: { join: {}, }, }; syncData.rooms.join[ROOM_ID] = { timeline: { events: [ event, ], limited: false, }, }; httpBackend.when("GET", "/sync").respond(200, syncData); await Promise.race([ Promise.all([ httpBackend.flushAllExpected(), ]), new Promise((_, reject) => { client.once("sync.unexpectedError", reject); }), ]); }, 20000); // additional timeout as this test can take quite a while }); matrix-js-sdk-9.11.0/spec/integ/000077500000000000000000000000001403504662500163445ustar00rootroot00000000000000matrix-js-sdk-9.11.0/spec/integ/devicelist-integ-spec.js000066400000000000000000000346711403504662500231040ustar00rootroot00000000000000/* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {TestClient} from '../TestClient'; import * as 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() { // 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/element-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/element-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(); } }); }); }); matrix-js-sdk-9.11.0/spec/integ/matrix-client-crypto.spec.js000066400000000000000000000642451403504662500237440ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /* 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`. */ // load olm before the sdk if possible import '../olm-loader'; import {logger} from '../../src/logger'; import * as testUtils from "../test-utils"; import * as utils from "../../src/utils"; import {TestClient} from "../TestClient"; import {CRYPTO_ENABLED} from "../../src/client"; 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).not.toEqual(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), ]).then(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(), ]).then(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), ]).then(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 prom = new Promise((resolve) => { httpBackend.when("PUT", path).respond(200, function(path, content) { resolve(content); return { event_id: "asdfgh", }; }); }); // it can take a while to process the key query return httpBackend.flush(path, 1).then(() => prom); } 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 (!CRYPTO_ENABLED) { return; } beforeEach(async function() { 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() { return Promise.resolve() .then(bobUploadsDeviceKeys) .then(aliDownloadsKeys); }); it("Ali gets keys with an invalid signature", function() { return 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([]); }); }); it("Ali gets keys with an incorrect userId", function() { 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}; }); return 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), ]); }).then(([bobDevices, eveDevices]) => { // should get an empty list expect(bobDevices).toEqual([]); expect(eveDevices).toEqual([]); }); }); it("Ali gets keys with an incorrect deviceId", function() { 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}; }); return 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([]); }); }); 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).not.toEqual(0); }); }); it("Ali sends a message", function() { aliTestClient.expectKeyQuery({device_keys: {[aliUserId]: {}}}); return Promise.resolve() .then(() => aliTestClient.start()) .then(() => bobTestClient.start()) .then(() => firstSync(aliTestClient)) .then(aliEnablesEncryption) .then(aliSendsFirstMessage); }); 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() { aliTestClient.expectKeyQuery({device_keys: {[aliUserId]: {}}}); return 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]); }); }); it("Bob receives two pre-key messages", function() { aliTestClient.expectKeyQuery({device_keys: {[aliUserId]: {}}}); return Promise.resolve() .then(() => aliTestClient.start()) .then(() => bobTestClient.start()) .then(() => firstSync(aliTestClient)) .then(aliEnablesEncryption) .then(aliSendsFirstMessage) .then(bobRecvMessage) .then(aliSendsMessage) .then(bobRecvMessage); }); 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).not.toEqual({}); expect(Object.keys(content.one_time_keys).length) .toBeGreaterThanOrEqual(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()); }); }); matrix-js-sdk-9.11.0/spec/integ/matrix-client-event-emitter.spec.js000066400000000000000000000307611403504662500252100ustar00rootroot00000000000000import * as utils from "../test-utils"; import {TestClient} from "../TestClient"; describe("MatrixClient events", function() { let client; let httpBackend; const selfUserId = "@alice:localhost"; const selfAccessToken = "aseukfgwef"; beforeEach(function() { const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); client = testClient.client; httpBackend = testClient.httpBackend; 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().then(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).not.toEqual( -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", ); }); }); }); }); matrix-js-sdk-9.11.0/spec/integ/matrix-client-event-timeline.spec.js000066400000000000000000000675041403504662500253520ustar00rootroot00000000000000import * as utils from "../test-utils"; import {EventTimeline} from "../../src/matrix"; import {logger} from "../../src/logger"; import {TestClient} from "../TestClient"; 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 prom = new Promise((resolve) => { client.on("sync", function(state) { logger.log("sync", state); if (state != "SYNCING") { return; } resolve(); }); }); return Promise.all([ httpBackend.flushAllExpected(), prom, ]); } describe("getEventTimeline support", function() { let httpBackend; let client; beforeEach(function() { const testClient = new TestClient(userId, "DEVICE", accessToken); client = testClient.client; httpBackend = testClient.httpBackend; }); afterEach(function() { if (client) { client.stopClient(); } return httpBackend.stop(); }); it("timeline support must be enabled to work", function() { return startClient(httpBackend, client).then(function() { const room = client.getRoom(roomId); const timelineSet = room.getTimelineSets()[0]; expect(function() { client.getEventTimeline(timelineSet, "event"); }).toThrow(); }); }); it("timeline support works when enabled", function() { const testClient = new TestClient( userId, "DEVICE", accessToken, undefined, {timelineSupport: true}, ); client = testClient.client; httpBackend = testClient.httpBackend; return startClient(httpBackend, client).then(() => { const room = client.getRoom(roomId); const timelineSet = room.getTimelineSets()[0]; expect(function() { client.getEventTimeline(timelineSet, "event"); }).not.toThrow(); }); }); it("scrollback should be able to scroll back to before a gappy /sync", function() { // need a client with timelineSupport disabled to make this work let room; return 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"); }); }); }); describe("MatrixClient event timelines", function() { let client = null; let httpBackend = null; beforeEach(function() { const testClient = new TestClient( userId, "DEVICE", accessToken, undefined, {timelineSupport: true}, ); client = testClient.client; httpBackend = testClient.httpBackend; 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 prom = new Promise((resolve, reject) => { 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"); }).then(resolve, reject); }); }); return Promise.all([ httpBackend.flushAllExpected(), prom, ]); }); 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() { // 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); return 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); }); }); }); matrix-js-sdk-9.11.0/spec/integ/matrix-client-methods.spec.js000066400000000000000000000332061403504662500240600ustar00rootroot00000000000000import * as utils from "../test-utils"; import {CRYPTO_ENABLED} from "../../src/client"; import {Filter, MemoryStore, Room} from "../../src/matrix"; import {TestClient} from "../TestClient"; describe("MatrixClient", function() { let client = null; let httpBackend = null; let store = null; const userId = "@alice:localhost"; const accessToken = "aseukfgwef"; beforeEach(function() { store = new MemoryStore(); const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, { store: store, }); httpBackend = testClient.httpBackend; client = testClient.client; }); afterEach(function() { httpBackend.verifyNoOutstandingExpectation(); return httpBackend.stop(); }); describe("uploadContent", function() { const buf = new Buffer('hello world'); it("should upload the file", function() { 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); const prom2 = 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); }); httpBackend.flush(); return prom2; }); it("should parse the response if rawResponse=false", function() { httpBackend.when( "POST", "/_matrix/media/r0/upload", ).check(function(req) { expect(req.opts.json).toBeFalsy(); }).respond(200, { "content_uri": "uri" }); const prom = client.uploadContent({ stream: buf, name: "hi.txt", type: "text/plain", }, { rawResponse: false, }).then(function(response) { expect(response.content_uri).toEqual("uri"); }); httpBackend.flush(); return prom; }); it("should parse errors into a MatrixError", function() { 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", }); const prom = 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"); }); httpBackend.flush(); return prom; }); it("should return a promise which can be cancelled", function() { 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); const prom2 = prom.then(function(response) { throw Error("request not aborted"); }, function(error) { expect(error).toEqual("aborted"); const uploads = client.getCurrentUploads(); expect(uploads.length).toEqual(0); }); const r = client.cancelUpload(prom); expect(r).toBe(true); return prom2; }); }); 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).then(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).then(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).then(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).then(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().then(function() { done(); }); }); }); describe("downloadKeys", function() { if (!CRYPTO_ENABLED) { return; } beforeEach(function() { return client.initCrypto(); }); it("should do an HTTP request and then store the keys", function() { 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, }, }); const prom = 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" }, }); }); httpBackend.flush(); return prom; }); }); describe("deleteDevice", function() { const auth = {a: 1}; it("should pass through an auth dict", function() { httpBackend.when( "DELETE", "/_matrix/client/r0/devices/my_device", ).check(function(req) { expect(req.data).toEqual({auth: auth}); }).respond(200); const prom = client.deleteDevice("my_device", auth); httpBackend.flush(); return prom; }); }); }); function assertObjectContains(obj, expected) { for (const k in expected) { if (expected.hasOwnProperty(k)) { expect(obj[k]).toEqual(expected[k]); } } } matrix-js-sdk-9.11.0/spec/integ/matrix-client-opts.spec.js000066400000000000000000000152621403504662500234040ustar00rootroot00000000000000import * as utils from "../test-utils"; import HttpBackend from "matrix-mock-request"; import {MatrixClient} from "../../src/matrix"; import {MatrixScheduler} from "../../src/scheduler"; import {MemoryStore} from "../../src/store/memory"; import {MatrixError} from "../../src/http-api"; 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() { 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 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").then(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())).not.toEqual( -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 MemoryStore(), baseUrl: baseUrl, userId: userId, accessToken: accessToken, scheduler: undefined, }); }); it("shouldn't retry sending events", function(done) { httpBackend.when("PUT", "/txn1").fail(500, new MatrixError({ errcode: "M_SOMETHING", error: "Ruh roh", })); client.sendTextMessage("!foo:bar", "a body", "txn1").then(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").then(function(res) { sentA = true; expect(sentB).toBe(true); }); client.sendTextMessage("!foo:bar", "b body", "txn2").then(function(res) { sentB = true; expect(sentA).toBe(false); }); httpBackend.flush("/txn2", 1).then(function() { httpBackend.flush("/txn1", 1).then(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").then(function(res) { expect(res.event_id).toEqual("foo"); done(); }); httpBackend.flush("/txn1", 1); }); }); }); matrix-js-sdk-9.11.0/spec/integ/matrix-client-retrying.spec.js000066400000000000000000000074731403504662500242670ustar00rootroot00000000000000import {EventStatus} from "../../src/matrix"; import {MatrixScheduler} from "../../src/scheduler"; import {Room} from "../../src/models/room"; import {TestClient} from "../TestClient"; describe("MatrixClient retrying", function() { let client = null; let httpBackend = null; let scheduler; const userId = "@alice:localhost"; const accessToken = "aseukfgwef"; const roomId = "!room:here"; let room; beforeEach(function() { scheduler = new MatrixScheduler(); const testClient = new TestClient( userId, "DEVICE", accessToken, undefined, {scheduler}, ); httpBackend = testClient.httpBackend; client = testClient.client; room = new 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() { }); }); }); matrix-js-sdk-9.11.0/spec/integ/matrix-client-room-timeline.spec.js000066400000000000000000000553371403504662500252060ustar00rootroot00000000000000import * as utils from "../test-utils"; import {EventStatus} from "../../src/models/event"; import {TestClient} from "../TestClient"; describe("MatrixClient room timelines", function() { 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() { // these tests should work with or without timelineSupport const testClient = new TestClient( userId, "DEVICE", accessToken, undefined, {timelineSupport: true}, ); httpBackend = testClient.httpBackend; client = testClient.client; 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(); return httpBackend.flush("/pushrules").then(function() { return httpBackend.flush("/filter"); }); }); 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).then(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").then( function() { expect(room.timeline[1].getId()).toEqual(eventId); httpBackend.flush("/sync", 1).then(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).then(function() { expect(room.timeline.length).toEqual(2); httpBackend.flush("/txn1", 1); promise.then(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).then(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).then(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).then(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).then(function() { expect(room.oldState.paginationToken).toEqual(sbEndTok); }); httpBackend.flush("/messages", 1).then(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); }); }); }); }); }); matrix-js-sdk-9.11.0/spec/integ/matrix-client-syncing.spec.js000066400000000000000000000624441403504662500240750ustar00rootroot00000000000000import {MatrixEvent} from "../../src/models/event"; import {EventTimeline} from "../../src/models/event-timeline"; import * as utils from "../test-utils"; import {TestClient} from "../TestClient"; describe("MatrixClient syncing", function() { 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() { const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken); httpBackend = testClient.httpBackend; client = testClient.client; 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().then(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().then(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).toBeDefined(); 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 prom = new Promise((resolve) => { httpBackend.when("GET", "/sync").check(function(req) { expect(req.queryParams.filter).toEqual("another_id"); 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(); }), prom, ]); }); 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); } }); matrix-js-sdk-9.11.0/spec/integ/megolm-integ.spec.js000066400000000000000000001111221403504662500222150ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import anotherjson from "another-json"; import * as utils from "../../src/utils"; import * as testUtils from "../test-utils"; import {TestClient} from "../TestClient"; 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() { 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/element-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', }); aliceTestClient.httpBackend.when( 'PUT', '/sendToDevice/org.matrix.room_key.withheld/', ).respond(200, {}); 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).not.toEqual(megolmSessionId); return { event_id: '$event_id', }; }); aliceTestClient.httpBackend.when( 'PUT', '/sendToDevice/org.matrix.room_key.withheld/', ).respond(200, {}); return Promise.all([ aliceTestClient.client.sendTextMessage(ROOM_ID, 'test2'), aliceTestClient.httpBackend.flushAllExpected(), ]); }); }); // https://github.com/vector-im/element-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'); }); }); it("Alice receives an untrusted megolm key, only to receive the trusted one shortly after", function() { const testClient = new TestClient( "@alice:localhost", "device2", "access_token2", ); const groupSession = new Olm.OutboundGroupSession(); groupSession.create(); const inboundGroupSession = new Olm.InboundGroupSession(); inboundGroupSession.create(groupSession.session_key()); const rawEvent = encryptMegolmEvent({ senderKey: testSenderKey, groupSession: groupSession, room_id: ROOM_ID, }); return testClient.client.initCrypto().then(() => { const keys = [{ room_id: ROOM_ID, algorithm: 'm.megolm.v1.aes-sha2', session_id: groupSession.session_id(), session_key: inboundGroupSession.export_session(0), sender_key: testSenderKey, }]; return testClient.client.importRoomKeys(keys, { untrusted: true }); }).then(() => { const event = testUtils.mkEvent({ event: true, ...rawEvent, room: ROOM_ID, }); return event.attemptDecryption(testClient.client._crypto, true).then(() => { expect(event.isKeySourceUntrusted()).toBeTruthy(); }); }).then(() => { const event = testUtils.mkEvent({ type: 'm.room_key', content: { room_id: ROOM_ID, algorithm: 'm.megolm.v1.aes-sha2', session_id: groupSession.session_id(), session_key: groupSession.session_key(), }, event: true, }); event._senderCurve25519Key = testSenderKey; return testClient.client._crypto._onRoomKeyEvent(event); }).then(() => { const event = testUtils.mkEvent({ event: true, ...rawEvent, room: ROOM_ID, }); return event.attemptDecryption(testClient.client._crypto, true).then(() => { expect(event.isKeySourceUntrusted()).toBeFalsy(); }); }); }); }); matrix-js-sdk-9.11.0/spec/olm-loader.js000066400000000000000000000021021403504662500176220ustar00rootroot00000000000000/* 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. */ import {logger} from '../src/logger'; import * as utils from "../src/utils"; // 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"); } // also try to set node crypto try { const crypto = require('crypto'); utils.setCrypto(crypto); } catch (err) { logger.log('nodejs was compiled without crypto support: some tests will fail'); } matrix-js-sdk-9.11.0/spec/test-utils.js000066400000000000000000000263761403504662500177270ustar00rootroot00000000000000// load olm before the sdk if possible import './olm-loader'; import {logger} from '../src/logger'; import {MatrixEvent} from "../src/models/event"; /** * 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 */ export function syncPromise(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 syncPromise(client, count-1); }); } /** * 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. */ export function mock(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] = jest.fn(); } } 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. */ export function mkEvent(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 */ export function mkPresence(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 */ export function mkMembership(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 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 */ export function mkMessage(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 mkEvent(opts); } /** * A mock implementation of webstorage * * @constructor */ export function MockStorageApi() { this.data = {}; } 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 */ export function awaitDecryption(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); }); }); } export function HttpResponse( httpLookups, acceptKeepalives, ignoreUnhandledSync, ) { this.httpLookups = httpLookups; this.acceptKeepalives = acceptKeepalives === undefined ? true : acceptKeepalives; this.ignoreUnhandledSync = ignoreUnhandledSync; this.pendingLookup = null; } HttpResponse.prototype.request = function( cb, method, path, qp, data, prefix, ) { if (path === HttpResponse.KEEP_ALIVE_PATH && this.acceptKeepalives) { return Promise.resolve(); } const next = this.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 (method === "GET" && path === "/sync" && this.ignoreUnhandledSync) { logger.log("MatrixClient[UT] Ignoring."); return new Promise(() => {}); } if (this.pendingLookup) { if (this.pendingLookup.method === method && this.pendingLookup.path === path) { return this.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(this.pendingLookup) + " JUST GOT: " + method + " " + path, ); } this.pendingLookup = { promise: new Promise(() => {}), method: method, path: path, }; return this.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); } else if (method === "GET" && path === "/sync" && this.ignoreUnhandledSync) { logger.log("MatrixClient[UT] Ignoring."); this.httpLookups.unshift(next); return new Promise(() => {}); } expect(true).toBe(false, "Expected different request. " + logLine); return new Promise(() => {}); }; HttpResponse.KEEP_ALIVE_PATH = "/_matrix/client/versions"; HttpResponse.PUSH_RULES_RESPONSE = { method: "GET", path: "/pushrules/", data: {}, }; HttpResponse.USER_ID = "@alice:bar"; HttpResponse.filterResponse = function(userId) { const filterPath = "/user/" + encodeURIComponent(userId) + "/filter"; return { method: "POST", path: filterPath, data: { filter_id: "f1lt3r" }, }; }; HttpResponse.SYNC_DATA = { next_batch: "s_5_3", presence: { events: [] }, rooms: {}, }; HttpResponse.SYNC_RESPONSE = { method: "GET", path: "/sync", data: HttpResponse.SYNC_DATA, }; HttpResponse.defaultResponses = function(userId) { return [ HttpResponse.PUSH_RULES_RESPONSE, HttpResponse.filterResponse(userId), HttpResponse.SYNC_RESPONSE, ]; }; export function setHttpResponses( client, responses, acceptKeepalives, ignoreUnhandledSyncs, ) { const httpResponseObj = new HttpResponse( responses, acceptKeepalives, ignoreUnhandledSyncs, ); const httpReq = httpResponseObj.request.bind(httpResponseObj); client._http = [ "authedRequest", "authedRequestWithPrefix", "getContentUri", "request", "requestWithPrefix", "uploadContent", ].reduce((r, k) => {r[k] = jest.fn(); return r;}, {}); client._http.authedRequest.mockImplementation(httpReq); client._http.authedRequestWithPrefix.mockImplementation(httpReq); client._http.requestWithPrefix.mockImplementation(httpReq); client._http.request.mockImplementation(httpReq); } matrix-js-sdk-9.11.0/spec/unit/000077500000000000000000000000001403504662500162155ustar00rootroot00000000000000matrix-js-sdk-9.11.0/spec/unit/ReEmitter.spec.ts000066400000000000000000000037761403504662500214330ustar00rootroot00000000000000/* Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { EventEmitter } from "events"; import { ReEmitter } from "../../src/ReEmitter"; const EVENTNAME = "UnknownEntry"; class EventSource extends EventEmitter { doTheThing() { this.emit(EVENTNAME, "foo", "bar"); } doAnError() { this.emit('error'); } } class EventTarget extends EventEmitter { } describe("ReEmitter", function() { it("Re-Emits events with the same args", function() { const src = new EventSource(); const tgt = new EventTarget(); const handler = jest.fn(); tgt.on(EVENTNAME, handler); const reEmitter = new ReEmitter(tgt); reEmitter.reEmit(src, [EVENTNAME]); src.doTheThing(); // Args should be the args passed to 'emit' after the event name, and // also the source object of the event which re-emitter adds expect(handler).toHaveBeenCalledWith("foo", "bar", src); }); it("Doesn't throw if no handler for 'error' event", function() { const src = new EventSource(); const tgt = new EventTarget(); const reEmitter = new ReEmitter(tgt); reEmitter.reEmit(src, ['error']); // without the workaround in ReEmitter, this would throw src.doAnError(); const handler = jest.fn(); tgt.on('error', handler); src.doAnError(); // Now we've attached an error handler, it should be called expect(handler).toHaveBeenCalled(); }); }); matrix-js-sdk-9.11.0/spec/unit/autodiscovery.spec.js000066400000000000000000000607241403504662500224150ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import MockHttpBackend from "matrix-mock-request"; import * as sdk from "../../src"; import {AutoDiscovery} from "../../src/autodiscovery"; describe("AutoDiscovery", function() { let httpBackend = null; beforeEach(function() { 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 SUCCESS / FAIL_PROMPT 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: "SUCCESS", error: null, // We still expect the base_url to be here for debugging purposes. base_url: "https://chat.example.org", }, "m.identity_server": { state: "FAIL_PROMPT", error: AutoDiscovery.ERROR_INVALID_IS_BASE_URL, base_url: null, }, }; expect(conf).toEqual(expected); }), ]); }); it("should return SUCCESS / FAIL_PROMPT 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: "SUCCESS", error: null, // We still expect the base_url to be here for debugging purposes. base_url: "https://chat.example.org", }, "m.identity_server": { state: "FAIL_PROMPT", error: AutoDiscovery.ERROR_INVALID_IS_BASE_URL, base_url: null, }, }; expect(conf).toEqual(expected); }), ]); }); it("should return SUCCESS / FAIL_PROMPT 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: "SUCCESS", error: null, // We still expect the base_url to be here for debugging purposes. base_url: "https://chat.example.org", }, "m.identity_server": { state: "FAIL_PROMPT", error: AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, base_url: "https://identity.example.org", }, }; expect(conf).toEqual(expected); }), ]); }); it("should return SUCCESS / FAIL_PROMPT 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: "SUCCESS", error: null, // We still expect the base_url to be here for debugging purposes base_url: "https://chat.example.org", }, "m.identity_server": { state: "FAIL_PROMPT", 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); }), ]); }); }); matrix-js-sdk-9.11.0/spec/unit/content-repo.spec.js000066400000000000000000000044761403504662500221340ustar00rootroot00000000000000import {getHttpUriForMxc} from "../../src/content-repo"; describe("ContentRepo", function() { const baseUrl = "https://my.home.server"; describe("getHttpUriForMxc", function() { it("should do nothing to HTTP URLs when allowing direct links", function() { const httpUrl = "http://example.com/image.jpeg"; expect( 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(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(getHttpUriForMxc(baseUrl, mxcUri)).toEqual( baseUrl + "/_matrix/media/r0/download/server.name/resourceid", ); }); it("should return the empty string for null input", function() { expect(getHttpUriForMxc(null)).toEqual(""); }); it("should return a thumbnail URL if a width/height/resize is specified", function() { const mxcUri = "mxc://server.name/resourceid"; expect(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(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(getHttpUriForMxc(baseUrl, mxcUri)).toEqual( baseUrl + "/_matrix/media/r0/download/server.name/resourceid#automade", ); }); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto.spec.js000066400000000000000000000404141403504662500210270ustar00rootroot00000000000000import '../olm-loader'; import {Crypto} from "../../src/crypto"; import {WebStorageSessionStore} from "../../src/store/session/webstorage"; import {MemoryCryptoStore} from "../../src/crypto/store/memory-crypto-store"; import {MockStorageApi} from "../MockStorageApi"; import {TestClient} from "../TestClient"; import {MatrixEvent} from "../../src/models/event"; import {Room} from "../../src/models/room"; import * as olmlib from "../../src/crypto/olmlib"; import {sleep} from "../../src/utils"; import {EventEmitter} from "events"; import {CRYPTO_ENABLED} from "../../src/client"; import {DeviceInfo} from "../../src/crypto/deviceinfo"; const Olm = global.Olm; describe("Crypto", function() { if (!CRYPTO_ENABLED) { return; } beforeAll(function() { return Olm.init(); }); it("Crypto exposes the correct olm library version", function() { expect(Crypto.getOlmVersion()[0]).toEqual(3); }); describe("encrypted events", function() { it("provides encryption information", async function() { const client = (new TestClient( "@alice:example.com", "deviceid", )).client; await client.initCrypto(); // unencrypted event const event = { getId: () => "$event_id", getSenderKey: () => null, getWireContent: () => {return {};}, }; let encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeFalsy(); // unknown sender (e.g. deleted device), forwarded megolm key (untrusted) event.getSenderKey = () => 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI'; event.getWireContent = () => {return {algorithm: olmlib.MEGOLM_ALGORITHM};}; event.getForwardingCurve25519KeyChain = () => ["not empty"]; event.isKeySourceUntrusted = () => false; event.getClaimedEd25519Key = () => 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeTruthy(); expect(encryptionInfo.authenticated).toBeFalsy(); expect(encryptionInfo.sender).toBeFalsy(); // known sender, megolm key from backup event.getForwardingCurve25519KeyChain = () => []; event.isKeySourceUntrusted = () => true; const device = new DeviceInfo("FLIBBLE"); device.keys["curve25519:FLIBBLE"] = 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI'; device.keys["ed25519:FLIBBLE"] = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; client._crypto._deviceList.getDeviceByIdentityKey = () => device; encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeTruthy(); expect(encryptionInfo.authenticated).toBeFalsy(); expect(encryptionInfo.sender).toBeTruthy(); expect(encryptionInfo.mismatchedSender).toBeFalsy(); // known sender, trusted megolm key, but bad ed25519key event.isKeySourceUntrusted = () => false; device.keys["ed25519:FLIBBLE"] = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeTruthy(); expect(encryptionInfo.authenticated).toBeTruthy(); expect(encryptionInfo.sender).toBeTruthy(); expect(encryptionInfo.mismatchedSender).toBeTruthy(); client.stopClient(); }); }); 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: jest.fn(), getKeyBackupVersion: jest.fn(), isGuest: jest.fn(), }; 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', { getId: jest.fn().mockReturnValue("$wedged"), getType: jest.fn().mockReturnValue('m.room.message'), getContent: jest.fn().mockReturnValue({ msgtype: 'm.bad.encrypted', }), getWireContent: jest.fn().mockReturnValue({ algorithm: 'm.olm.v1.curve25519-aes-sha2', sender_key: 'this is a key', }), getSender: jest.fn().mockReturnValue('@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).not.toBe("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)) .toBeDefined(); // 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).not.toBe("m.bad.encrypted"); await sleep(1); // the room key request should be gone since we've now decrypted everything expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) .toBeFalsy(); }, ); 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)) .toBeDefined(); }); it("uses a new txnid for re-requesting keys", async function() { jest.useFakeTimers(); const event = new MatrixEvent({ sender: "@bob:example.com", room_id: "!someroom", content: { algorithm: olmlib.MEGOLM_ALGORITHM, session_id: "sessionid", sender_key: "senderkey", }, }); // replace Alice's sendToDevice function with a mock aliceClient.sendToDevice = jest.fn().mockResolvedValue(undefined); aliceClient.startClient(); // make a room key request, and record the transaction ID for the // sendToDevice call await aliceClient.cancelAndResendEventRoomKeyRequest(event); // key requests get queued until the sync has finished, but we don't // let the client set up enough for that to happen, so gut-wrench a bit // to force it to send now. aliceClient._crypto._outgoingRoomKeyRequestManager.sendQueuedRequests(); jest.runAllTimers(); await Promise.resolve(); expect(aliceClient.sendToDevice).toBeCalledTimes(1); const txnId = aliceClient.sendToDevice.mock.calls[0][2]; // give the room key request manager time to update the state // of the request await Promise.resolve(); // cancel and resend the room key request await aliceClient.cancelAndResendEventRoomKeyRequest(event); jest.runAllTimers(); await Promise.resolve(); // cancelAndResend will call sendToDevice twice: // the first call to sendToDevice will be the cancellation // the second call to sendToDevice will be the key request expect(aliceClient.sendToDevice).toBeCalledTimes(3); expect(aliceClient.sendToDevice.mock.calls[2][2]).not.toBe(txnId); }); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/000077500000000000000000000000001403504662500175355ustar00rootroot00000000000000matrix-js-sdk-9.11.0/spec/unit/crypto/CrossSigningInfo.spec.js000066400000000000000000000242121403504662500242510ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import '../../olm-loader'; import { CrossSigningInfo, createCryptoStoreCacheCallbacks, } from '../../../src/crypto/CrossSigning'; import { IndexedDBCryptoStore, } from '../../../src/crypto/store/indexeddb-crypto-store'; import {MemoryCryptoStore} from '../../../src/crypto/store/memory-crypto-store'; import 'fake-indexeddb/auto'; import 'jest-localstorage-mock'; import {OlmDevice} from "../../../src/crypto/OlmDevice"; import {logger} from '../../../src/logger'; const userId = "@alice:example.com"; // Private key for tests only const testKey = new Uint8Array([ 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, 0x05, 0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, ]); const types = [ { type: "master", shouldCache: true }, { type: "self_signing", shouldCache: true }, { type: "user_signing", shouldCache: true }, { type: "invalid", shouldCache: false }, ]; const badKey = Uint8Array.from(testKey); badKey[0] ^= 1; const masterKeyPub = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"; describe("CrossSigningInfo.getCrossSigningKey", function() { if (!global.Olm) { logger.warn('Not running megolm backup unit tests: libolm not present'); return; } beforeAll(function() { return global.Olm.init(); }); it("should throw if no callback is provided", async () => { const info = new CrossSigningInfo(userId); await expect(info.getCrossSigningKey("master")).rejects.toThrow(); }); it.each(types)("should throw if the callback returns falsey", async ({type, shouldCache}) => { const info = new CrossSigningInfo(userId, { getCrossSigningKey: () => false, }); await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey"); }); it("should throw if the expected key doesn't come back", async () => { const info = new CrossSigningInfo(userId, { getCrossSigningKey: () => masterKeyPub, }); await expect(info.getCrossSigningKey("master", "")).rejects.toThrow(); }); it("should return a key from its callback", async () => { const info = new CrossSigningInfo(userId, { getCrossSigningKey: () => testKey, }); const [pubKey, pkSigning] = await info.getCrossSigningKey("master", masterKeyPub); expect(pubKey).toEqual(masterKeyPub); // check that the pkSigning object corresponds to the pubKey const signature = pkSigning.sign("message"); const util = new global.Olm.Utility(); try { util.ed25519_verify(pubKey, "message", signature); } finally { util.free(); } }); it.each(types)("should request a key from the cache callback (if set)" + " and does not call app if one is found" + " %o", async ({ type, shouldCache }) => { const getCrossSigningKey = jest.fn().mockImplementation(() => { if (shouldCache) { return Promise.reject(new Error("Regular callback called")); } else { return Promise.resolve(testKey); } }); const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); const info = new CrossSigningInfo( userId, { getCrossSigningKey }, { getCrossSigningKeyCache }, ); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); expect(pubKey).toEqual(masterKeyPub); expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); if (shouldCache) { expect(getCrossSigningKeyCache.mock.calls[0][0]).toBe(type); } }); it.each(types)("should store a key with the cache callback (if set)", async ({ type, shouldCache }) => { const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); const info = new CrossSigningInfo( userId, { getCrossSigningKey }, { storeCrossSigningKeyCache }, ); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); expect(pubKey).toEqual(masterKeyPub); expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0); if (shouldCache) { expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type); expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey); } }); it.each(types)("does not store a bad key to the cache", async ({ type, shouldCache }) => { const getCrossSigningKey = jest.fn().mockResolvedValue(badKey); const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); const info = new CrossSigningInfo( userId, { getCrossSigningKey }, { storeCrossSigningKeyCache }, ); await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow(); expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0); }); it.each(types)("does not store a value to the cache if it came from the cache", async ({ type, shouldCache }) => { const getCrossSigningKey = jest.fn().mockImplementation(() => { if (shouldCache) { return Promise.reject(new Error("Regular callback called")); } else { return Promise.resolve(testKey); } }); const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); const storeCrossSigningKeyCache = jest.fn().mockRejectedValue( new Error("Tried to store a value from cache"), ); const info = new CrossSigningInfo( userId, { getCrossSigningKey }, { getCrossSigningKeyCache, storeCrossSigningKeyCache }, ); expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); expect(pubKey).toEqual(masterKeyPub); }); it.each(types)("requests a key from the cache callback (if set) and then calls app" + " if one is not found", async ({ type, shouldCache }) => { const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); const getCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); const storeCrossSigningKeyCache = jest.fn(); const info = new CrossSigningInfo( userId, { getCrossSigningKey }, { getCrossSigningKeyCache, storeCrossSigningKeyCache }, ); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); expect(pubKey).toEqual(masterKeyPub); expect(getCrossSigningKey.mock.calls.length).toBe(1); expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); /* Also expect that the cache gets updated */ expect(storeCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); }); it.each(types)("requests a key from the cache callback (if set) and then" + " calls app if that key doesn't match", async ({ type, shouldCache }) => { const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); const getCrossSigningKeyCache = jest.fn().mockResolvedValue(badKey); const storeCrossSigningKeyCache = jest.fn(); const info = new CrossSigningInfo( userId, { getCrossSigningKey }, { getCrossSigningKeyCache, storeCrossSigningKeyCache }, ); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); expect(pubKey).toEqual(masterKeyPub); expect(getCrossSigningKey.mock.calls.length).toBe(1); expect(getCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); /* Also expect that the cache gets updated */ expect(storeCrossSigningKeyCache.mock.calls.length).toBe(shouldCache ? 1 : 0); }); }); /* * Note that MemoryStore is weird. It's only used for testing - as far as I can tell, * it's not possible to get one in normal execution unless you hack as we do here. */ describe.each([ ["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(global.indexedDB, "tests")], ["LocalStorageCryptoStore", () => new IndexedDBCryptoStore(undefined, "tests")], ["MemoryCryptoStore", () => { const store = new IndexedDBCryptoStore(undefined, "tests"); store._backend = new MemoryCryptoStore(); store._backendPromise = Promise.resolve(store._backend); return store; }], ])("CrossSigning > createCryptoStoreCacheCallbacks [%s]", function(name, dbFactory) { let store; beforeAll(() => { store = dbFactory(); }); beforeEach(async () => { await store.deleteAllData(); }); it("should cache data to the store and retrieve it", async () => { await store.startup(); const olmDevice = new OlmDevice(store); const { getCrossSigningKeyCache, storeCrossSigningKeyCache } = createCryptoStoreCacheCallbacks(store, olmDevice); await storeCrossSigningKeyCache("self_signing", testKey); // If we've not saved anything, don't expect anything // Definitely don't accidentally return the wrong key for the type const nokey = await getCrossSigningKeyCache("self", ""); expect(nokey).toBeNull(); const key = await getCrossSigningKeyCache("self_signing", ""); expect(new Uint8Array(key)).toEqual(testKey); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/DeviceList.spec.js000066400000000000000000000165571403504662500230750ustar00rootroot00000000000000/* 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. */ import {logger} from "../../../src/logger"; import * as utils from "../../../src/utils"; import {MemoryCryptoStore} from "../../../src/crypto/store/memory-crypto-store"; import {DeviceList} from "../../../src/crypto/DeviceList"; 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": {}, }, }, }, }; const signedDeviceList2 = { "failures": {}, "device_keys": { "@test2:sw1v.org": { "QJVRHWAKGH": { "signatures": { "@test2:sw1v.org": { "ed25519:QJVRHWAKGH": "w1xxdLe1iIqzEFHLRVYQeuiM6t2N2ZRiI8s5nDKxf054BP8" + "1CPEX/AQXh5BhkKAVMlKnwg4T9zU1/wBALeajk3", }, }, "user_id": "@test2:sw1v.org", "keys": { "ed25519:QJVRHWAKGH": "Ig0/C6T+bBII1l2By2Wnnvtjp1nm/iXBlLU5/QESFXL", "curve25519:QJVRHWAKGH": "YR3eQnUvTQzGlWih4rsmJkKxpDxzgkgIgsBd1DEZIbm", }, "algorithms": [ "m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2", ], "device_id": "QJVRHWAKGH", "unsigned": {}, }, }, }, }; describe('DeviceList', function() { let downloadSpy; let cryptoStore; let deviceLists = []; beforeEach(function() { deviceLists = []; downloadSpy = jest.fn(); cryptoStore = new MemoryCryptoStore(); }); afterEach(function() { for (const dl of deviceLists) { dl.stop(); } }); function createTestDeviceList(keyDownloadChunkSize = 250) { const baseApis = { downloadKeysForUsers: downloadSpy, getUserId: () => '@test1:sw1v.org', deviceId: 'HGKAWHRVJQ', }; const mockOlm = { verifySignature: function(key, message, signature) {}, }; const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize); deviceLists.push(dl); return dl; } it("should successfully download and store device keys", function() { const dl = createTestDeviceList(); dl.startTrackingDeviceList('@test1:sw1v.org'); const queryDefer1 = utils.defer(); downloadSpy.mockReturnValue(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 = utils.defer(); downloadSpy.mockReturnValue(queryDefer1.promise); const prom1 = dl.refreshOutdatedDeviceLists(); expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {}); downloadSpy.mockReset(); // outdated notif arrives while the request is in flight. const queryDefer2 = utils.defer(); downloadSpy.mockReturnValue(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.mockReset(); const dl2 = createTestDeviceList(); const queryDefer3 = utils.defer(); downloadSpy.mockReturnValue(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']); }); }); it("should download device keys in batches", function() { const dl = createTestDeviceList(1); dl.startTrackingDeviceList('@test1:sw1v.org'); dl.startTrackingDeviceList('@test2:sw1v.org'); const queryDefer1 = utils.defer(); downloadSpy.mockReturnValueOnce(queryDefer1.promise); const queryDefer2 = utils.defer(); downloadSpy.mockReturnValueOnce(queryDefer2.promise); const prom1 = dl.refreshOutdatedDeviceLists(); expect(downloadSpy).toBeCalledTimes(2); expect(downloadSpy).toHaveBeenNthCalledWith(1, ['@test1:sw1v.org'], {}); expect(downloadSpy).toHaveBeenNthCalledWith(2, ['@test2:sw1v.org'], {}); queryDefer1.resolve(utils.deepCopy(signedDeviceList)); queryDefer2.resolve(utils.deepCopy(signedDeviceList2)); return prom1.then(() => { const storedKeys1 = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); expect(Object.keys(storedKeys1)).toEqual(['HGKAWHRVJQ']); const storedKeys2 = dl.getRawStoredDevicesForUser('@test2:sw1v.org'); expect(Object.keys(storedKeys2)).toEqual(['QJVRHWAKGH']); }); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/algorithms/000077500000000000000000000000001403504662500217065ustar00rootroot00000000000000matrix-js-sdk-9.11.0/spec/unit/crypto/algorithms/megolm.spec.js000066400000000000000000000627601403504662500244700ustar00rootroot00000000000000import '../../../olm-loader'; import * as algorithms from "../../../../src/crypto/algorithms"; import {MemoryCryptoStore} from "../../../../src/crypto/store/memory-crypto-store"; import {MockStorageApi} from "../../../MockStorageApi"; import * as testUtils from "../../../test-utils"; import {OlmDevice} from "../../../../src/crypto/OlmDevice"; import {Crypto} from "../../../../src/crypto"; import {logger} from "../../../../src/logger"; import {MatrixEvent} from "../../../../src/models/event"; import {TestClient} from "../../../TestClient"; import {Room} from "../../../../src/models/room"; import * as olmlib from "../../../../src/crypto/olmlib"; const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; const MegolmEncryption = algorithms.ENCRYPTION_CLASSES['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; } beforeAll(function() { return Olm.init(); }); let megolmDecryption; let mockOlmLib; let mockCrypto; let mockBaseApis; beforeEach(async function() { 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 = jest.fn(); mockOlmLib.encryptMessageForDevice = jest.fn().mockResolvedValue(undefined); 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.mockReturnValue(deviceInfo); mockOlmLib.ensureOlmSessionsForDevices.mockResolvedValue({ '@alice:foo': {'alidevice': { sessionId: 'alisession', }}, }); const awaitEncryptForDevice = new Promise((res, rej) => { mockOlmLib.encryptMessageForDevice.mockImplementation(() => { res(); return Promise.resolve(); }); }); mockBaseApis.sendToDevice = jest.fn(); // 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).toBeCalledTimes(1); const call = mockOlmLib.encryptMessageForDevice.mock.calls[0]; const payload = call[6]; expect(payload.type).toEqual("m.forwarded_room_key"); expect(payload.content).toMatchObject({ 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).toBeDefined(); }); }); 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 = jest.fn(); const failureHandler = jest.fn((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).not.toHaveBeenCalled(); 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 = jest.fn(); await olmDevice.init(); mockBaseApis.claimOneTimeKeys = jest.fn().mockReturnValue(Promise.resolve({ one_time_keys: { '@alice:home.server': { aliceDevice: { 'signed_curve25519:flooble': { key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI', signatures: { '@alice:home.server': { 'ed25519:aliceDevice': 'totally valid', }, }, }, }, }, }, })); mockBaseApis.sendToDevice = jest.fn().mockResolvedValue(undefined); mockCrypto.downloadKeys.mockReturnValue(Promise.resolve({ '@alice:home.server': { aliceDevice: { deviceId: 'aliceDevice', isBlocked: jest.fn().mockReturnValue(false), isUnverified: jest.fn().mockReturnValue(false), getIdentityKey: jest.fn().mockReturnValue( 'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE', ), getFingerprint: jest.fn().mockReturnValue(''), }, }, })); mockCrypto.checkDeviceTrust.mockReturnValue({ isVerified: () => false, }); const megolmEncryption = new MegolmEncryption({ userId: '@user:id', crypto: mockCrypto, olmDevice: olmDevice, baseApis: mockBaseApis, roomId: ROOM_ID, config: { rotation_period_ms: 9999999999999, }, }); const mockRoom = { getEncryptionTargetMembers: jest.fn().mockReturnValue( [{userId: "@alice:home.server"}], ), getBlacklistUnverifiedDevices: jest.fn().mockReturnValue(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).toHaveBeenCalledWith( [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 2000, ); expect(mockCrypto.downloadKeys).toHaveBeenCalledWith( ['@alice:home.server'], false, ); expect(mockBaseApis.sendToDevice).toHaveBeenCalled(); expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith( [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 2000, ); mockBaseApis.claimOneTimeKeys.mockReset(); 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).not.toHaveBeenCalled(); // likewise they should show the same session ID expect(ct2.session_id).toEqual(ct1.session_id); }); }); it("notifies devices that have been blocked", async function() { const aliceClient = (new TestClient( "@alice:example.com", "alicedevice", )).client; const bobClient1 = (new TestClient( "@bob:example.com", "bobdevice1", )).client; const bobClient2 = (new TestClient( "@bob:example.com", "bobdevice2", )).client; await Promise.all([ aliceClient.initCrypto(), bobClient1.initCrypto(), bobClient2.initCrypto(), ]); const aliceDevice = aliceClient._crypto._olmDevice; const bobDevice1 = bobClient1._crypto._olmDevice; const bobDevice2 = bobClient2._crypto._olmDevice; const encryptionCfg = { "algorithm": "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const room = new Room(roomId, aliceClient, "@alice:example.com", {}); room.getEncryptionTargetMembers = async function() { return [{userId: "@bob:example.com"}]; }; room.setBlacklistUnverifiedDevices(true); aliceClient.store.storeRoom(room); await aliceClient.setRoomEncryption(roomId, encryptionCfg); const BOB_DEVICES = { bobdevice1: { user_id: "@bob:example.com", device_id: "bobdevice1", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { "ed25519:Dynabook": bobDevice1.deviceEd25519Key, "curve25519:Dynabook": bobDevice1.deviceCurve25519Key, }, verified: 0, }, bobdevice2: { user_id: "@bob:example.com", device_id: "bobdevice2", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { "ed25519:Dynabook": bobDevice2.deviceEd25519Key, "curve25519:Dynabook": bobDevice2.deviceCurve25519Key, }, verified: -1, }, }; aliceClient._crypto._deviceList.storeDevicesForUser( "@bob:example.com", BOB_DEVICES, ); aliceClient._crypto._deviceList.downloadKeys = async function(userIds) { return this._getDevicesFromStore(userIds); }; let run = false; aliceClient.sendToDevice = async (msgtype, contentMap) => { run = true; expect(msgtype).toBe("org.matrix.room_key.withheld"); delete contentMap["@bob:example.com"].bobdevice1.session_id; delete contentMap["@bob:example.com"].bobdevice2.session_id; expect(contentMap).toStrictEqual({ '@bob:example.com': { bobdevice1: { algorithm: "m.megolm.v1.aes-sha2", room_id: roomId, code: 'm.unverified', reason: 'The sender has disabled encrypting to unverified devices.', sender_key: aliceDevice.deviceCurve25519Key, }, bobdevice2: { algorithm: "m.megolm.v1.aes-sha2", room_id: roomId, code: 'm.blacklisted', reason: 'The sender has blocked you.', sender_key: aliceDevice.deviceCurve25519Key, }, }, }); }; const event = new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$event", content: { msgtype: "m.text", body: "secret", }, }); await aliceClient._crypto.encryptEvent(event, room); expect(run).toBe(true); aliceClient.stopClient(); bobClient1.stopClient(); bobClient2.stopClient(); }); it("notifies devices when unable to create olm session", async function() { const aliceClient = (new TestClient( "@alice:example.com", "alicedevice", )).client; const bobClient = (new TestClient( "@bob:example.com", "bobdevice", )).client; await Promise.all([ aliceClient.initCrypto(), bobClient.initCrypto(), ]); const aliceDevice = aliceClient._crypto._olmDevice; const bobDevice = bobClient._crypto._olmDevice; 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); aliceRoom.getEncryptionTargetMembers = async () => { return [ { userId: "@alice:example.com", membership: "join", }, { userId: "@bob:example.com", membership: "join", }, ]; }; const BOB_DEVICES = { bobdevice: { user_id: "@bob:example.com", device_id: "bobdevice", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { "ed25519:bobdevice": bobDevice.deviceEd25519Key, "curve25519:bobdevice": bobDevice.deviceCurve25519Key, }, known: true, verified: 1, }, }; aliceClient._crypto._deviceList.storeDevicesForUser( "@bob:example.com", BOB_DEVICES, ); aliceClient._crypto._deviceList.downloadKeys = async function(userIds) { return this._getDevicesFromStore(userIds); }; aliceClient.claimOneTimeKeys = async () => { // Bob has no one-time keys return { one_time_keys: {}, }; }; const sendPromise = new Promise((resolve, reject) => { aliceClient.sendToDevice = async (msgtype, contentMap) => { expect(msgtype).toBe("org.matrix.room_key.withheld"); expect(contentMap).toStrictEqual({ '@bob:example.com': { bobdevice: { algorithm: "m.megolm.v1.aes-sha2", code: 'm.no_olm', reason: 'Unable to establish a secure channel.', sender_key: aliceDevice.deviceCurve25519Key, }, }, }); resolve(); }; }); const event = new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$event", content: {}, }); await aliceClient._crypto.encryptEvent(event, aliceRoom); await sendPromise; }); it("throws an error describing why it doesn't have a key", async function() { const aliceClient = (new TestClient( "@alice:example.com", "alicedevice", )).client; const bobClient = (new TestClient( "@bob:example.com", "bobdevice", )).client; await Promise.all([ aliceClient.initCrypto(), bobClient.initCrypto(), ]); const bobDevice = bobClient._crypto._olmDevice; const roomId = "!someroom"; aliceClient._crypto._onToDeviceEvent(new MatrixEvent({ type: "org.matrix.room_key.withheld", sender: "@bob:example.com", content: { algorithm: "m.megolm.v1.aes-sha2", room_id: roomId, session_id: "session_id", sender_key: bobDevice.deviceCurve25519Key, code: "m.blacklisted", reason: "You have been blocked", }, })); await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({ type: "m.room.encrypted", sender: "@bob:example.com", event_id: "$event", room_id: roomId, content: { algorithm: "m.megolm.v1.aes-sha2", ciphertext: "blablabla", device_id: "bobdevice", sender_key: bobDevice.deviceCurve25519Key, session_id: "session_id", }, }))).rejects.toThrow("The sender has blocked you."); }); it("throws an error describing the lack of an olm session", async function() { const aliceClient = (new TestClient( "@alice:example.com", "alicedevice", )).client; const bobClient = (new TestClient( "@bob:example.com", "bobdevice", )).client; await Promise.all([ aliceClient.initCrypto(), bobClient.initCrypto(), ]); aliceClient._crypto.downloadKeys = async () => {}; const bobDevice = bobClient._crypto._olmDevice; const roomId = "!someroom"; const now = Date.now(); aliceClient._crypto._onToDeviceEvent(new MatrixEvent({ type: "org.matrix.room_key.withheld", sender: "@bob:example.com", content: { algorithm: "m.megolm.v1.aes-sha2", room_id: roomId, session_id: "session_id", sender_key: bobDevice.deviceCurve25519Key, code: "m.no_olm", reason: "Unable to establish a secure channel.", }, })); await new Promise((resolve) => { setTimeout(resolve, 100); }); await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({ type: "m.room.encrypted", sender: "@bob:example.com", event_id: "$event", room_id: roomId, content: { algorithm: "m.megolm.v1.aes-sha2", ciphertext: "blablabla", device_id: "bobdevice", sender_key: bobDevice.deviceCurve25519Key, session_id: "session_id", }, origin_server_ts: now, }))).rejects.toThrow("The sender was unable to establish a secure channel."); }); it("throws an error to indicate a wedged olm session", async function() { const aliceClient = (new TestClient( "@alice:example.com", "alicedevice", )).client; const bobClient = (new TestClient( "@bob:example.com", "bobdevice", )).client; await Promise.all([ aliceClient.initCrypto(), bobClient.initCrypto(), ]); const bobDevice = bobClient._crypto._olmDevice; aliceClient._crypto.downloadKeys = async () => {}; const roomId = "!someroom"; const now = Date.now(); // pretend we got an event that we can't decrypt aliceClient._crypto._onToDeviceEvent(new MatrixEvent({ type: "m.room.encrypted", sender: "@bob:example.com", content: { msgtype: "m.bad.encrypted", algorithm: "m.megolm.v1.aes-sha2", session_id: "session_id", sender_key: bobDevice.deviceCurve25519Key, }, })); await new Promise((resolve) => { setTimeout(resolve, 100); }); await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({ type: "m.room.encrypted", sender: "@bob:example.com", event_id: "$event", room_id: roomId, content: { algorithm: "m.megolm.v1.aes-sha2", ciphertext: "blablabla", device_id: "bobdevice", sender_key: bobDevice.deviceCurve25519Key, session_id: "session_id", }, origin_server_ts: now, }))).rejects.toThrow("The secure channel with the sender was corrupted."); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/algorithms/olm.spec.js000066400000000000000000000244051403504662500237710ustar00rootroot00000000000000/* 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. */ import '../../../olm-loader'; import {MemoryCryptoStore} from "../../../../src/crypto/store/memory-crypto-store"; import {MockStorageApi} from "../../../MockStorageApi"; import {logger} from "../../../../src/logger"; import {OlmDevice} from "../../../../src/crypto/OlmDevice"; import * as olmlib from "../../../../src/crypto/olmlib"; import {DeviceInfo} from "../../../../src/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("OlmDevice", function() { if (!global.Olm) { logger.warn('Not running megolm unit tests: libolm not present'); return; } beforeAll(function() { return global.Olm.init(); }); let aliceOlmDevice; let bobOlmDevice; beforeEach(async function() { 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('exports picked account and olm sessions', async function() { const sessionId = await setupSession(aliceOlmDevice, bobOlmDevice); const exported = await bobOlmDevice.export(); // At this moment only Alice (the “initiator” in setupSession) has a session expect(exported.sessions).toEqual([]); const MESSAGE = ( "The olm or proteus is an aquatic salamander" + " in the family Proteidae" ); const ciphertext = await aliceOlmDevice.encryptMessage( bobOlmDevice.deviceCurve25519Key, sessionId, MESSAGE, ); const bobRecreatedOlmDevice = makeOlmDevice(); bobRecreatedOlmDevice.init({ fromExportedDevice: exported }); const decrypted = await bobRecreatedOlmDevice.createInboundSession( aliceOlmDevice.deviceCurve25519Key, ciphertext.type, ciphertext.body, ); expect(decrypted.payload).toEqual(MESSAGE); const exportedAgain = await bobRecreatedOlmDevice.export(); // this time we expect Bob to have a session to export expect(exportedAgain.sessions).toHaveLength(1); const MESSAGE_2 = ( "In contrast to most amphibians," + " the olm is entirely aquatic" ); const ciphertext2 = await aliceOlmDevice.encryptMessage( bobOlmDevice.deviceCurve25519Key, sessionId, MESSAGE_2, ); const bobRecreatedAgainOlmDevice = makeOlmDevice(); bobRecreatedAgainOlmDevice.init({ fromExportedDevice: exportedAgain }); // Note: "decrypted_2" does not have the same structure as "decrypted" const decrypted2 = await bobRecreatedAgainOlmDevice.decryptMessage( aliceOlmDevice.deviceCurve25519Key, decrypted.session_id, ciphertext2.type, ciphertext2.body, ); expect(decrypted2).toEqual(MESSAGE_2); }); 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); }); it("avoids deadlocks when two tasks are ensuring the same devices", async function() { // This test checks whether `ensureOlmSessionsForDevices` properly // handles multiple tasks in flight ensuring some set of devices in // common without deadlocks. let claimRequestCount = 0; const baseApis = { claimOneTimeKeys: () => { // simulate a very slow server (.5 seconds to respond) claimRequestCount++; return new Promise((resolve, reject) => { setTimeout(reject, 500); }); }, }; const deviceBobA = DeviceInfo.fromStorage({ keys: { "curve25519:BOB-A": "akey", }, }, "BOB-A"); const deviceBobB = DeviceInfo.fromStorage({ keys: { "curve25519:BOB-B": "bkey", }, }, "BOB-B"); // There's no required ordering of devices per user, so here we // create two different orderings so that each task reserves a // device the other task needs before continuing. const devicesByUserAB = { "@bob:example.com": [ deviceBobA, deviceBobB, ], }; const devicesByUserBA = { "@bob:example.com": [ deviceBobB, deviceBobA, ], }; function alwaysSucceed(promise) { // swallow any exception thrown by a promise, so that // Promise.all doesn't abort return promise.catch(() => {}); } const task1 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices( aliceOlmDevice, baseApis, devicesByUserAB, )); // After a single tick through the first task, it should have // claimed ownership of all devices to avoid deadlocking others. expect(Object.keys(aliceOlmDevice._sessionsInProgress).length).toBe(2); const task2 = alwaysSucceed(olmlib.ensureOlmSessionsForDevices( aliceOlmDevice, baseApis, devicesByUserBA, )); // The second task should not have changed the ownership count, as // it's waiting on the first task. expect(Object.keys(aliceOlmDevice._sessionsInProgress).length).toBe(2); // Track the tasks, but don't await them yet. const promises = Promise.all([ task1, task2, ]); await new Promise((resolve) => { setTimeout(resolve, 200); }); // After .2s, the first task should have made an initial claim request. expect(claimRequestCount).toBe(1); await promises; // After waiting for both tasks to complete, the first task should // have failed, so the second task should have tried to create a // new session and will have called claimOneTimeKeys expect(claimRequestCount).toBe(2); }); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/backup.spec.js000066400000000000000000000552001403504662500222730ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import '../../olm-loader'; import {logger} from "../../../src/logger"; import * as olmlib from "../../../src/crypto/olmlib"; import {MatrixClient} from "../../../src/client"; import {MatrixEvent} from "../../../src/models/event"; import * as algorithms from "../../../src/crypto/algorithms"; import {WebStorageSessionStore} from "../../../src/store/session/webstorage"; import {MemoryCryptoStore} from "../../../src/crypto/store/memory-crypto-store"; import {MockStorageApi} from "../../MockStorageApi"; import * as testUtils from "../../test-utils"; import {OlmDevice} from "../../../src/crypto/OlmDevice"; import {Crypto} from "../../../src/crypto"; import {resetCrossSigningKeys} from "./crypto-utils"; const Olm = global.Olm; const MegolmDecryption = algorithms.DECRYPTION_CLASSES['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", }, }; const keys = {}; function getCrossSigningKey(type) { return keys[type]; } function saveCrossSigningKeys(k) { Object.assign(keys, k); } function makeTestClient(sessionStore, cryptoStore) { const scheduler = [ "getQueueForEvent", "queueEvent", "removeEventFromQueue", "setProcessFunction", ].reduce((r, k) => {r[k] = jest.fn(); 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] = jest.fn(); return r;}, {}); store.getSavedSync = jest.fn().mockReturnValue(Promise.resolve(null)); store.getSavedSyncToken = jest.fn().mockReturnValue(Promise.resolve(null)); store.setSyncData = jest.fn().mockReturnValue(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, cryptoCallbacks: { getCrossSigningKey, saveCrossSigningKeys }, }); } describe("MegolmBackup", function() { if (!global.Olm) { logger.warn('Not running megolm backup unit tests: libolm not present'); return; } beforeAll(function() { return Olm.init(); }); let olmDevice; let mockOlmLib; let mockCrypto; let mockStorage; let sessionStore; let cryptoStore; let megolmDecryption; beforeEach(async function() { 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 = jest.fn(); mockOlmLib.encryptMessageForDevice = jest.fn().mockResolvedValue(undefined); }); 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 = jest.fn(); 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).toBeLessThanOrEqual(1); if (numCalls >= 2) { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many timmes")); return Promise.resolve({}); } expect(method).toBe("PUT"); expect(path).toBe("/room_keys/keys"); expect(queryParams.version).toBe(1); expect(data.rooms[ROOM_ID].sessions).toBeDefined(); expect(data.rooms[ROOM_ID].sessions).toHaveProperty( 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('signs backups with the cross-signing master key', async 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; await client.initCrypto(); let privateKeys; client.uploadDeviceSigningKeys = async function(e) {return;}; client.uploadKeySignatures = async function(e) {return;}; client.on("crossSigning.saveCrossSigningKeys", function(e) { privateKeys = e; }); client.on("crossSigning.getKey", function(e) { e.done(privateKeys[e.type]); }); await resetCrossSigningKeys(client); let numCalls = 0; await new Promise((resolve, reject) => { client._http.authedRequest = function( callback, method, path, queryParams, data, opts, ) { ++numCalls; expect(numCalls).toBeLessThanOrEqual(1); if (numCalls >= 2) { // exit out of retry loop if there's something wrong reject(new Error("authedRequest called too many timmes")); return Promise.resolve({}); } expect(method).toBe("POST"); expect(path).toBe("/room_keys/version"); try { // make sure auth_data is signed by the master key olmlib.pkVerify( data.auth_data, client.getCrossSigningId(), "@alice:bar", ); } catch (e) { reject(e); return Promise.resolve({}); } resolve(); return Promise.resolve({}); }; client.createKeyBackupVersion({ algorithm: "m.megolm_backup.v1", auth_data: { public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", }, }); }); 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] = jest.fn(); 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] = jest.fn(); return r;}, {}); store.getSavedSync = jest.fn().mockReturnValue(Promise.resolve(null)); store.getSavedSyncToken = jest.fn().mockReturnValue(Promise.resolve(null)); store.setSyncData = jest.fn().mockReturnValue(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).toBeLessThanOrEqual(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).toBeDefined(); expect(data.rooms[ROOM_ID].sessions).toHaveProperty( 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'); expect(res.untrusted).toBeTruthy(); // keys from backup are untrusted }); }); 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'); }); }); it('has working cache functions', async function() { const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]); await client._crypto.storeSessionBackupPrivateKey(key); const result = await client._crypto.getSessionBackupPrivateKey(); expect(new Uint8Array(result)).toEqual(key); }); it('caches session backup keys as it encounters them', async function() { const cachedNull = await client._crypto.getSessionBackupPrivateKey(); expect(cachedNull).toBeNull(); client._http.authedRequest = function() { return Promise.resolve(KEY_BACKUP_DATA); }; await new Promise((resolve) => { client.restoreKeyBackupWithRecoveryKey( "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d", ROOM_ID, SESSION_ID, BACKUP_INFO, { cacheCompleteCallback: resolve }, ); }); const cachedKey = await client._crypto.getSessionBackupPrivateKey(); expect(cachedKey).not.toBeNull(); }); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/cross-signing.spec.js000066400000000000000000001016061403504662500236150ustar00rootroot00000000000000/* Copyright 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. */ import '../../olm-loader'; import anotherjson from 'another-json'; import * as olmlib from "../../../src/crypto/olmlib"; import {TestClient} from '../../TestClient'; import {HttpResponse, setHttpResponses} from '../../test-utils'; import { resetCrossSigningKeys } from "./crypto-utils"; import { MatrixError } from '../../../src/http-api'; import {logger} from '../../../src/logger'; async function makeTestClient(userInfo, options, keys) { if (!keys) keys = {}; function getCrossSigningKey(type) { return keys[type]; } function saveCrossSigningKeys(k) { Object.assign(keys, k); } if (!options) options = {}; options.cryptoCallbacks = Object.assign( {}, { getCrossSigningKey, saveCrossSigningKeys }, options.cryptoCallbacks || {}, ); const client = (new TestClient( userInfo.userId, userInfo.deviceId, undefined, undefined, options, )).client; await client.initCrypto(); return client; } describe("Cross Signing", function() { if (!global.Olm) { logger.warn('Not running megolm backup unit tests: libolm not present'); return; } beforeAll(function() { return global.Olm.init(); }); it("should sign the master key with the device key", async function() { const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, ); alice.uploadDeviceSigningKeys = jest.fn(async (auth, keys) => { await olmlib.verifySignature( alice._crypto._olmDevice, keys.master_key, "@alice:example.com", "Osborne2", alice._crypto._olmDevice.deviceEd25519Key, ); }); alice.uploadKeySignatures = async () => {}; alice.setAccountData = async () => {}; alice.getAccountDataFromServer = async () => {}; // set Alice's cross-signing key await alice.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async func => await func({}), }); expect(alice.uploadDeviceSigningKeys).toHaveBeenCalled(); }); it("should abort bootstrap if device signing auth fails", async function() { const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, ); alice.uploadDeviceSigningKeys = async (auth, keys) => { const errorResponse = { session: "sessionId", flows: [ { stages: [ "m.login.password", ], }, ], params: {}, }; // If we're not just polling for flows, add on error rejecting the // auth attempt. if (auth) { Object.assign(errorResponse, { completed: [], error: "Invalid password", errcode: "M_FORBIDDEN", }); } const error = new MatrixError(errorResponse); error.httpStatus == 401; throw error; }; alice.uploadKeySignatures = async () => {}; alice.setAccountData = async () => {}; alice.getAccountDataFromServer = async () => { }; const authUploadDeviceSigningKeys = async func => await func({}); // Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass // through failure, stopping before actually applying changes. let bootstrapDidThrow = false; try { await alice.bootstrapCrossSigning({ authUploadDeviceSigningKeys, }); } catch (e) { if (e.errcode === "M_FORBIDDEN") { bootstrapDidThrow = true; } } expect(bootstrapDidThrow).toBeTruthy(); }); it("should upload a signature when a user is verified", async function() { const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, ); alice.uploadDeviceSigningKeys = async () => {}; alice.uploadKeySignatures = async () => {}; // set Alice's cross-signing key await resetCrossSigningKeys(alice); // Alice downloads Bob's device key alice._crypto._deviceList.storeCrossSigningForUser("@bob:example.com", { keys: { master: { user_id: "@bob:example.com", usage: ["master"], keys: { "ed25519:bobs+master+pubkey": "bobs+master+pubkey", }, }, }, }); // Alice verifies Bob's key const promise = new Promise((resolve, reject) => { alice.uploadKeySignatures = (...args) => { resolve(...args); }; }); await alice.setDeviceVerified("@bob:example.com", "bobs+master+pubkey", true); // Alice should send a signature of Bob's key to the server await promise; }); it("should get cross-signing keys from sync", async function() { const masterKey = new Uint8Array([ 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, 0x05, 0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, ]); const selfSigningKey = new Uint8Array([ 0x1e, 0xf4, 0x01, 0x6d, 0x4f, 0xa1, 0x73, 0x66, 0x6b, 0xf8, 0x93, 0xf5, 0xb0, 0x4d, 0x17, 0xc0, 0x17, 0xb5, 0xa5, 0xf6, 0x59, 0x11, 0x8b, 0x49, 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, ]); const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, { cryptoCallbacks: { // will be called to sign our own device getCrossSigningKey: type => { if (type === 'master') { return masterKey; } else { return selfSigningKey; } }, }, }, ); const keyChangePromise = new Promise((resolve, reject) => { alice.once("crossSigning.keysChanged", async (e) => { resolve(e); await alice.checkOwnCrossSigningTrust({ allowPrivateKeyRequests: true, }); }); }); const uploadSigsPromise = new Promise((resolve, reject) => { alice.uploadKeySignatures = jest.fn(async (content) => { await olmlib.verifySignature( alice._crypto._olmDevice, content["@alice:example.com"][ "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk" ], "@alice:example.com", "Osborne2", alice._crypto._olmDevice.deviceEd25519Key, ); olmlib.pkVerify( content["@alice:example.com"]["Osborne2"], "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", "@alice:example.com", ); resolve(); }); }); const deviceInfo = alice._crypto._deviceList._devices["@alice:example.com"] .Osborne2; const aliceDevice = { user_id: "@alice:example.com", device_id: "Osborne2", }; aliceDevice.keys = deviceInfo.keys; aliceDevice.algorithms = deviceInfo.algorithms; await alice._crypto._signObject(aliceDevice); olmlib.pkSign(aliceDevice, selfSigningKey, "@alice:example.com"); // feed sync result that includes master key, ssk, device key const responses = [ HttpResponse.PUSH_RULES_RESPONSE, { method: "POST", path: "/keys/upload", data: { one_time_key_counts: { curve25519: 100, signed_curve25519: 100, }, }, }, HttpResponse.filterResponse("@alice:example.com"), { method: "GET", path: "/sync", data: { next_batch: "abcdefg", device_lists: { changed: [ "@alice:example.com", "@bob:example.com", ], }, }, }, { method: "POST", path: "/keys/query", data: { "failures": {}, "device_keys": { "@alice:example.com": { "Osborne2": aliceDevice, }, }, "master_keys": { "@alice:example.com": { user_id: "@alice:example.com", usage: ["master"], keys: { "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", }, }, }, "self_signing_keys": { "@alice:example.com": { user_id: "@alice:example.com", usage: ["self-signing"], keys: { "ed25519:EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ": "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", }, signatures: { "@alice:example.com": { "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": "Wqx/HXR851KIi8/u/UX+fbAMtq9Uj8sr8FsOcqrLfVYa6lAmbXs" + "Vhfy4AlZ3dnEtjgZx0U0QDrghEn2eYBeOCA", }, }, }, }, }, }, { method: "POST", path: "/keys/upload", data: { one_time_key_counts: { curve25519: 100, signed_curve25519: 100, }, }, }, ]; setHttpResponses(alice, responses, true, true); await alice.startClient(); // once ssk is confirmed, device key should be trusted await keyChangePromise; await uploadSigsPromise; const aliceTrust = alice.checkUserTrust("@alice:example.com"); expect(aliceTrust.isCrossSigningVerified()).toBeTruthy(); expect(aliceTrust.isTofu()).toBeTruthy(); expect(aliceTrust.isVerified()).toBeTruthy(); const aliceDeviceTrust = alice.checkDeviceTrust("@alice:example.com", "Osborne2"); expect(aliceDeviceTrust.isCrossSigningVerified()).toBeTruthy(); expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy(); expect(aliceDeviceTrust.isTofu()).toBeTruthy(); expect(aliceDeviceTrust.isVerified()).toBeTruthy(); }); it("should use trust chain to determine device verification", async function() { const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, ); alice.uploadDeviceSigningKeys = async () => {}; alice.uploadKeySignatures = async () => {}; // set Alice's cross-signing key await resetCrossSigningKeys(alice); // Alice downloads Bob's ssk and device key const bobMasterSigning = new global.Olm.PkSigning(); const bobMasterPrivkey = bobMasterSigning.generate_seed(); const bobMasterPubkey = bobMasterSigning.init_with_seed(bobMasterPrivkey); const bobSigning = new global.Olm.PkSigning(); const bobPrivkey = bobSigning.generate_seed(); const bobPubkey = bobSigning.init_with_seed(bobPrivkey); const bobSSK = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { ["ed25519:" + bobPubkey]: bobPubkey, }, }; const sskSig = bobMasterSigning.sign(anotherjson.stringify(bobSSK)); bobSSK.signatures = { "@bob:example.com": { ["ed25519:" + bobMasterPubkey]: sskSig, }, }; alice._crypto._deviceList.storeCrossSigningForUser("@bob:example.com", { keys: { master: { user_id: "@bob:example.com", usage: ["master"], keys: { ["ed25519:" + bobMasterPubkey]: bobMasterPubkey, }, }, self_signing: bobSSK, }, firstUse: 1, unsigned: {}, }); const bobDevice = { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], keys: { "curve25519:Dynabook": "somePubkey", "ed25519:Dynabook": "someOtherPubkey", }, }; const sig = bobSigning.sign(anotherjson.stringify(bobDevice)); bobDevice.signatures = { "@bob:example.com": { ["ed25519:" + bobPubkey]: sig, }, }; alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, }); // Bob's device key should be TOFU const bobTrust = alice.checkUserTrust("@bob:example.com"); expect(bobTrust.isVerified()).toBeFalsy(); expect(bobTrust.isTofu()).toBeTruthy(); const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust.isVerified()).toBeFalsy(); expect(bobDeviceTrust.isTofu()).toBeTruthy(); // Alice verifies Bob's SSK alice.uploadKeySignatures = () => {}; await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); // Bob's device key should be trusted const bobTrust2 = alice.checkUserTrust("@bob:example.com"); expect(bobTrust2.isCrossSigningVerified()).toBeTruthy(); expect(bobTrust2.isTofu()).toBeTruthy(); const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust2.isCrossSigningVerified()).toBeTruthy(); expect(bobDeviceTrust2.isLocallyVerified()).toBeFalsy(); expect(bobDeviceTrust2.isTofu()).toBeTruthy(); }); it("should trust signatures received from other devices", async function() { const aliceKeys = {}; const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, null, aliceKeys, ); alice._crypto._deviceList.startTrackingDeviceList("@bob:example.com"); alice._crypto._deviceList.stopTrackingAllDeviceLists = () => {}; alice.uploadDeviceSigningKeys = async () => {}; alice.uploadKeySignatures = async () => {}; // set Alice's cross-signing key await resetCrossSigningKeys(alice); const selfSigningKey = new Uint8Array([ 0x1e, 0xf4, 0x01, 0x6d, 0x4f, 0xa1, 0x73, 0x66, 0x6b, 0xf8, 0x93, 0xf5, 0xb0, 0x4d, 0x17, 0xc0, 0x17, 0xb5, 0xa5, 0xf6, 0x59, 0x11, 0x8b, 0x49, 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, ]); const keyChangePromise = new Promise((resolve, reject) => { alice._crypto._deviceList.once("userCrossSigningUpdated", (userId) => { if (userId === "@bob:example.com") { resolve(); } }); }); const deviceInfo = alice._crypto._deviceList._devices["@alice:example.com"] .Osborne2; const aliceDevice = { user_id: "@alice:example.com", device_id: "Osborne2", }; aliceDevice.keys = deviceInfo.keys; aliceDevice.algorithms = deviceInfo.algorithms; await alice._crypto._signObject(aliceDevice); const bobOlmAccount = new global.Olm.Account(); bobOlmAccount.create(); const bobKeys = JSON.parse(bobOlmAccount.identity_keys()); const bobDevice = { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { "ed25519:Dynabook": bobKeys.ed25519, "curve25519:Dynabook": bobKeys.curve25519, }, }; const deviceStr = anotherjson.stringify(bobDevice); bobDevice.signatures = { "@bob:example.com": { "ed25519:Dynabook": bobOlmAccount.sign(deviceStr), }, }; olmlib.pkSign(bobDevice, selfSigningKey, "@bob:example.com"); const bobMaster = { user_id: "@bob:example.com", usage: ["master"], keys: { "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", }, }; olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com"); // Alice downloads Bob's keys // - device key // - ssk // - master key signed by her usk (pretend that it was signed by another // of Alice's devices) const responses = [ HttpResponse.PUSH_RULES_RESPONSE, { method: "POST", path: "/keys/upload", data: { one_time_key_counts: { curve25519: 100, signed_curve25519: 100, }, }, }, HttpResponse.filterResponse("@alice:example.com"), { method: "GET", path: "/sync", data: { next_batch: "abcdefg", device_lists: { changed: [ "@bob:example.com", ], }, }, }, { method: "POST", path: "/keys/query", data: { "failures": {}, "device_keys": { "@alice:example.com": { "Osborne2": aliceDevice, }, "@bob:example.com": { "Dynabook": bobDevice, }, }, "master_keys": { "@bob:example.com": bobMaster, }, "self_signing_keys": { "@bob:example.com": { user_id: "@bob:example.com", usage: ["self-signing"], keys: { "ed25519:EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ": "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", }, signatures: { "@bob:example.com": { "ed25519:nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk": "2KLiufImvEbfJuAFvsaZD+PsL8ELWl7N1u9yr/9hZvwRghBfQMB" + "LAI86b1kDV9+Cq1lt85ykReeCEzmTEPY2BQ", }, }, }, }, }, }, { method: "POST", path: "/keys/upload", data: { one_time_key_counts: { curve25519: 100, signed_curve25519: 100, }, }, }, ]; setHttpResponses(alice, responses); await alice.startClient(); await keyChangePromise; // Bob's device key should be trusted const bobTrust = alice.checkUserTrust("@bob:example.com"); expect(bobTrust.isCrossSigningVerified()).toBeTruthy(); expect(bobTrust.isTofu()).toBeTruthy(); const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust.isCrossSigningVerified()).toBeTruthy(); expect(bobDeviceTrust.isLocallyVerified()).toBeFalsy(); expect(bobDeviceTrust.isTofu()).toBeTruthy(); }); it("should dis-trust an unsigned device", async function() { const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, ); alice.uploadDeviceSigningKeys = async () => {}; alice.uploadKeySignatures = async () => {}; // set Alice's cross-signing key await resetCrossSigningKeys(alice); // Alice downloads Bob's ssk and device key // (NOTE: device key is not signed by ssk) const bobMasterSigning = new global.Olm.PkSigning(); const bobMasterPrivkey = bobMasterSigning.generate_seed(); const bobMasterPubkey = bobMasterSigning.init_with_seed(bobMasterPrivkey); const bobSigning = new global.Olm.PkSigning(); const bobPrivkey = bobSigning.generate_seed(); const bobPubkey = bobSigning.init_with_seed(bobPrivkey); const bobSSK = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { ["ed25519:" + bobPubkey]: bobPubkey, }, }; const sskSig = bobMasterSigning.sign(anotherjson.stringify(bobSSK)); bobSSK.signatures = { "@bob:example.com": { ["ed25519:" + bobMasterPubkey]: sskSig, }, }; alice._crypto._deviceList.storeCrossSigningForUser("@bob:example.com", { keys: { master: { user_id: "@bob:example.com", usage: ["master"], keys: { ["ed25519:" + bobMasterPubkey]: bobMasterPubkey, }, }, self_signing: bobSSK, }, firstUse: 1, unsigned: {}, }); const bobDevice = { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], keys: { "curve25519:Dynabook": "somePubkey", "ed25519:Dynabook": "someOtherPubkey", }, }; alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, }); // Bob's device key should be untrusted const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust.isVerified()).toBeFalsy(); expect(bobDeviceTrust.isTofu()).toBeFalsy(); // Alice verifies Bob's SSK await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); // Bob's device key should be untrusted const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust2.isVerified()).toBeFalsy(); expect(bobDeviceTrust2.isTofu()).toBeFalsy(); }); it("should dis-trust a user when their ssk changes", async function() { const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, ); alice.uploadDeviceSigningKeys = async () => {}; alice.uploadKeySignatures = async () => {}; await resetCrossSigningKeys(alice); // Alice downloads Bob's keys const bobMasterSigning = new global.Olm.PkSigning(); const bobMasterPrivkey = bobMasterSigning.generate_seed(); const bobMasterPubkey = bobMasterSigning.init_with_seed(bobMasterPrivkey); const bobSigning = new global.Olm.PkSigning(); const bobPrivkey = bobSigning.generate_seed(); const bobPubkey = bobSigning.init_with_seed(bobPrivkey); const bobSSK = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { ["ed25519:" + bobPubkey]: bobPubkey, }, }; const sskSig = bobMasterSigning.sign(anotherjson.stringify(bobSSK)); bobSSK.signatures = { "@bob:example.com": { ["ed25519:" + bobMasterPubkey]: sskSig, }, }; alice._crypto._deviceList.storeCrossSigningForUser("@bob:example.com", { keys: { master: { user_id: "@bob:example.com", usage: ["master"], keys: { ["ed25519:" + bobMasterPubkey]: bobMasterPubkey, }, }, self_signing: bobSSK, }, firstUse: 1, unsigned: {}, }); const bobDevice = { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], keys: { "curve25519:Dynabook": "somePubkey", "ed25519:Dynabook": "someOtherPubkey", }, }; const bobDeviceString = anotherjson.stringify(bobDevice); const sig = bobSigning.sign(bobDeviceString); bobDevice.signatures = {}; bobDevice.signatures["@bob:example.com"] = {}; bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey] = sig; alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, }); // Alice verifies Bob's SSK alice.uploadKeySignatures = () => {}; await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); // Bob's device key should be trusted const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust.isVerified()).toBeTruthy(); expect(bobDeviceTrust.isTofu()).toBeTruthy(); // Alice downloads new SSK for Bob const bobMasterSigning2 = new global.Olm.PkSigning(); const bobMasterPrivkey2 = bobMasterSigning2.generate_seed(); const bobMasterPubkey2 = bobMasterSigning2.init_with_seed(bobMasterPrivkey2); const bobSigning2 = new global.Olm.PkSigning(); const bobPrivkey2 = bobSigning2.generate_seed(); const bobPubkey2 = bobSigning2.init_with_seed(bobPrivkey2); const bobSSK2 = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { ["ed25519:" + bobPubkey2]: bobPubkey2, }, }; const sskSig2 = bobMasterSigning2.sign(anotherjson.stringify(bobSSK2)); bobSSK2.signatures = { "@bob:example.com": { ["ed25519:" + bobMasterPubkey2]: sskSig2, }, }; alice._crypto._deviceList.storeCrossSigningForUser("@bob:example.com", { keys: { master: { user_id: "@bob:example.com", usage: ["master"], keys: { ["ed25519:" + bobMasterPubkey2]: bobMasterPubkey2, }, }, self_signing: bobSSK2, }, firstUse: 0, unsigned: {}, }); // Bob's and his device should be untrusted const bobTrust = alice.checkUserTrust("@bob:example.com"); expect(bobTrust.isVerified()).toBeFalsy(); expect(bobTrust.isTofu()).toBeFalsy(); const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust2.isVerified()).toBeFalsy(); expect(bobDeviceTrust2.isTofu()).toBeFalsy(); // Alice verifies Bob's SSK alice.uploadKeySignatures = () => {}; await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey2, true); // Bob should be trusted but not his device const bobTrust2 = alice.checkUserTrust("@bob:example.com"); expect(bobTrust2.isVerified()).toBeTruthy(); const bobDeviceTrust3 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust3.isVerified()).toBeFalsy(); // Alice gets new signature for device const sig2 = bobSigning2.sign(bobDeviceString); bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey2] = sig2; alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, }); // Bob's device should be trusted again (but not TOFU) const bobTrust3 = alice.checkUserTrust("@bob:example.com"); expect(bobTrust3.isVerified()).toBeTruthy(); const bobDeviceTrust4 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust4.isCrossSigningVerified()).toBeTruthy(); }); it("should offer to upgrade device verifications to cross-signing", async function() { let upgradeResolveFunc; const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, { cryptoCallbacks: { shouldUpgradeDeviceVerifications: (verifs) => { expect(verifs.users["@bob:example.com"]).toBeDefined(); upgradeResolveFunc(); return ["@bob:example.com"]; }, }, }, ); const bob = await makeTestClient( {userId: "@bob:example.com", deviceId: "Dynabook"}, ); bob.uploadDeviceSigningKeys = async () => {}; bob.uploadKeySignatures = async () => {}; // set Bob's cross-signing key await resetCrossSigningKeys(bob); alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: { algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], keys: { "curve25519:Dynabook": bob._crypto._olmDevice.deviceCurve25519Key, "ed25519:Dynabook": bob._crypto._olmDevice.deviceEd25519Key, }, verified: 1, known: true, }, }); alice._crypto._deviceList.storeCrossSigningForUser( "@bob:example.com", bob._crypto._crossSigningInfo.toStorage(), ); alice.uploadDeviceSigningKeys = async () => {}; alice.uploadKeySignatures = async () => {}; // when alice sets up cross-signing, she should notice that bob's // cross-signing key is signed by his Dynabook, which alice has // verified, and ask if the device verification should be upgraded to a // cross-signing verification let upgradePromise = new Promise((resolve) => { upgradeResolveFunc = resolve; }); await resetCrossSigningKeys(alice); await upgradePromise; const bobTrust = alice.checkUserTrust("@bob:example.com"); expect(bobTrust.isCrossSigningVerified()).toBeTruthy(); expect(bobTrust.isTofu()).toBeTruthy(); // "forget" that Bob is trusted delete alice._crypto._deviceList._crossSigningInfo["@bob:example.com"] .keys.master.signatures["@alice:example.com"]; const bobTrust2 = alice.checkUserTrust("@bob:example.com"); expect(bobTrust2.isCrossSigningVerified()).toBeFalsy(); expect(bobTrust2.isTofu()).toBeTruthy(); upgradePromise = new Promise((resolve) => { upgradeResolveFunc = resolve; }); alice._crypto._deviceList.emit("userCrossSigningUpdated", "@bob:example.com"); await new Promise((resolve) => { alice._crypto.on("userTrustStatusChanged", resolve); }); await upgradePromise; const bobTrust3 = alice.checkUserTrust("@bob:example.com"); expect(bobTrust3.isCrossSigningVerified()).toBeTruthy(); expect(bobTrust3.isTofu()).toBeTruthy(); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/crypto-utils.js000066400000000000000000000032101403504662500225450ustar00rootroot00000000000000import {IndexedDBCryptoStore} from '../../../src/crypto/store/indexeddb-crypto-store'; // needs to be phased out and replaced with bootstrapSecretStorage, // but that is doing too much extra stuff for it to be an easy transition. export async function resetCrossSigningKeys(client, { level, authUploadDeviceSigningKeys = async func => await func(), } = {}) { const crypto = client._crypto; const oldKeys = Object.assign({}, crypto._crossSigningInfo.keys); try { await crypto._crossSigningInfo.resetKeys(level); await crypto._signObject(crypto._crossSigningInfo.keys.master); // write a copy locally so we know these are trusted keys await crypto._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { crypto._cryptoStore.storeCrossSigningKeys( txn, crypto._crossSigningInfo.keys); }, ); } catch (e) { // If anything failed here, revert the keys so we know to try again from the start // next time. crypto._crossSigningInfo.keys = oldKeys; throw e; } crypto._baseApis.emit("crossSigning.keysChanged", {}); await crypto._afterCrossSigningLocalKeyChange(); } export async function createSecretStorageKey() { const decryption = new global.Olm.PkDecryption(); const storagePublicKey = decryption.generate_key(); const storagePrivateKey = decryption.get_private_key(); decryption.free(); return { // `pubkey` not used anymore with symmetric 4S keyInfo: { pubkey: storagePublicKey }, privateKey: storagePrivateKey, }; } matrix-js-sdk-9.11.0/spec/unit/crypto/outgoing-room-key-requests.spec.js000066400000000000000000000055321403504662500262750ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { IndexedDBCryptoStore, } from '../../../src/crypto/store/indexeddb-crypto-store'; import {MemoryCryptoStore} from '../../../src/crypto/store/memory-crypto-store'; import 'fake-indexeddb/auto'; import 'jest-localstorage-mock'; import { ROOM_KEY_REQUEST_STATES, } from '../../../src/crypto/OutgoingRoomKeyRequestManager'; const requests = [ { requestId: "A", requestBody: { session_id: "A", room_id: "A" }, state: ROOM_KEY_REQUEST_STATES.SENT, }, { requestId: "B", requestBody: { session_id: "B", room_id: "B" }, state: ROOM_KEY_REQUEST_STATES.SENT, }, { requestId: "C", requestBody: { session_id: "C", room_id: "C" }, state: ROOM_KEY_REQUEST_STATES.UNSENT, }, ]; describe.each([ ["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(global.indexedDB, "tests")], ["LocalStorageCryptoStore", () => new IndexedDBCryptoStore(undefined, "tests")], ["MemoryCryptoStore", () => { const store = new IndexedDBCryptoStore(undefined, "tests"); store._backend = new MemoryCryptoStore(); store._backendPromise = Promise.resolve(store._backend); return store; }], ])("Outgoing room key requests [%s]", function(name, dbFactory) { let store; beforeAll(async () => { store = dbFactory(); await store.startup(); await Promise.all(requests.map((request) => store.getOrAddOutgoingRoomKeyRequest(request), )); }); it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", async () => { const r = await store.getAllOutgoingRoomKeyRequestsByState(ROOM_KEY_REQUEST_STATES.SENT); expect(r).toHaveLength(2); requests.filter((e) => e.state == ROOM_KEY_REQUEST_STATES.SENT).forEach((e) => { expect(r).toContainEqual(e); }); }); test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", async () => { const r = await store.getOutgoingRoomKeyRequestByState([ROOM_KEY_REQUEST_STATES.SENT]); expect(r).not.toBeNull(); expect(r).not.toBeUndefined(); expect(r.state).toEqual(ROOM_KEY_REQUEST_STATES.SENT); expect(requests).toContainEqual(r); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/secrets.spec.js000066400000000000000000000617171403504662500225100ustar00rootroot00000000000000/* 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. */ import '../../olm-loader'; import * as olmlib from "../../../src/crypto/olmlib"; import {SECRET_STORAGE_ALGORITHM_V1_AES} from "../../../src/crypto/SecretStorage"; import {MatrixEvent} from "../../../src/models/event"; import {TestClient} from '../../TestClient'; import {makeTestClients} from './verification/util'; import {encryptAES} from "../../../src/crypto/aes"; import {resetCrossSigningKeys, createSecretStorageKey} from "./crypto-utils"; import {logger} from '../../../src/logger'; import * as utils from "../../../src/utils"; try { const crypto = require('crypto'); utils.setCrypto(crypto); } catch (err) { logger.log('nodejs was compiled without crypto support'); } async function makeTestClient(userInfo, options) { const client = (new TestClient( userInfo.userId, userInfo.deviceId, undefined, undefined, options, )).client; // Make it seem as if we've synced and thus the store can be trusted to // contain valid account data. client.isInitialSyncComplete = function() { return true; }; await client.initCrypto(); // No need to download keys for these tests client._crypto.downloadKeys = async function() {}; return client; } // Wrapper around pkSign to return a signed object. pkSign returns the // signature, rather than the signed object. function sign(obj, key, userId) { olmlib.pkSign(obj, key, userId); return obj; } describe("Secrets", function() { if (!global.Olm) { logger.warn('Not running megolm backup unit tests: libolm not present'); return; } beforeAll(function() { return global.Olm.init(); }); it("should store and retrieve a secret", async function() { const key = new Uint8Array(16); for (let i = 0; i < 16; i++) key[i] = i; const signing = new global.Olm.PkSigning(); const signingKey = signing.generate_seed(); const signingPubKey = signing.init_with_seed(signingKey); const signingkeyInfo = { user_id: "@alice:example.com", usage: ['master'], keys: { ['ed25519:' + signingPubKey]: signingPubKey, }, }; const getKey = jest.fn(e => { expect(Object.keys(e.keys)).toEqual(["abc"]); return ['abc', key]; }); const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, { cryptoCallbacks: { getCrossSigningKey: t => signingKey, getSecretStorageKey: getKey, }, }, ); alice._crypto._crossSigningInfo.setKeys({ master: signingkeyInfo, }); const secretStorage = alice._crypto._secretStorage; alice.setAccountData = async function(eventType, contents, callback) { alice.store.storeAccountDataEvents([ new MatrixEvent({ type: eventType, content: contents, }), ]); if (callback) { callback(); } }; const keyAccountData = { algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, }; await alice._crypto._crossSigningInfo.signObject(keyAccountData, 'master'); alice.store.storeAccountDataEvents([ new MatrixEvent({ type: "m.secret_storage.key.abc", content: keyAccountData, }), ]); expect(await secretStorage.isStored("foo")).toBeFalsy(); await secretStorage.store("foo", "bar", ["abc"]); expect(await secretStorage.isStored("foo")).toBeTruthy(); expect(await secretStorage.get("foo")).toBe("bar"); expect(getKey).toHaveBeenCalled(); }); it("should throw if given a key that doesn't exist", async function() { const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, ); try { await alice.storeSecret("foo", "bar", ["this secret does not exist"]); // should be able to use expect(...).toThrow() but mocha still fails // the test even when it throws for reasons I have no inclination to debug expect(true).toBeFalsy(); } catch (e) { } }); it("should refuse to encrypt with zero keys", async function() { const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, ); try { await alice.storeSecret("foo", "bar", []); expect(true).toBeFalsy(); } catch (e) { } }); it("should encrypt with default key if keys is null", async function() { const key = new Uint8Array(16); for (let i = 0; i < 16; i++) key[i] = i; const getKey = jest.fn(e => { expect(Object.keys(e.keys)).toEqual([newKeyId]); return [newKeyId, key]; }); let keys = {}; const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, { cryptoCallbacks: { getCrossSigningKey: t => keys[t], saveCrossSigningKeys: k => keys = k, getSecretStorageKey: getKey, }, }, ); alice.setAccountData = async function(eventType, contents, callback) { alice.store.storeAccountDataEvents([ new MatrixEvent({ type: eventType, content: contents, }), ]); }; resetCrossSigningKeys(alice); const { keyId: newKeyId } = await alice.addSecretStorageKey( SECRET_STORAGE_ALGORITHM_V1_AES, ); // we don't await on this because it waits for the event to come down the sync // which won't happen in the test setup alice.setDefaultSecretStorageKeyId(newKeyId); await alice.storeSecret("foo", "bar"); const accountData = alice.getAccountData('foo'); expect(accountData.getContent().encrypted).toBeTruthy(); }); it("should refuse to encrypt if no keys given and no default key", async function() { const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, ); try { await alice.storeSecret("foo", "bar"); expect(true).toBeFalsy(); } catch (e) { } }); it("should request secrets from other clients", async function() { const [osborne2, vax] = await makeTestClients( [ {userId: "@alice:example.com", deviceId: "Osborne2"}, {userId: "@alice:example.com", deviceId: "VAX"}, ], { cryptoCallbacks: { onSecretRequested: (userId, deviceId, requestId, secretName, deviceTrust) => { expect(secretName).toBe("foo"); return "bar"; }, }, }, ); const vaxDevice = vax.client._crypto._olmDevice; const osborne2Device = osborne2.client._crypto._olmDevice; const secretStorage = osborne2.client._crypto._secretStorage; osborne2.client._crypto._deviceList.storeDevicesForUser("@alice:example.com", { "VAX": { user_id: "@alice:example.com", device_id: "VAX", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { "ed25519:VAX": vaxDevice.deviceEd25519Key, "curve25519:VAX": vaxDevice.deviceCurve25519Key, }, }, }); vax.client._crypto._deviceList.storeDevicesForUser("@alice:example.com", { "Osborne2": { user_id: "@alice:example.com", device_id: "Osborne2", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { "ed25519:Osborne2": osborne2Device.deviceEd25519Key, "curve25519:Osborne2": osborne2Device.deviceCurve25519Key, }, }, }); await osborne2Device.generateOneTimeKeys(1); const otks = (await osborne2Device.getOneTimeKeys()).curve25519; await osborne2Device.markKeysAsPublished(); await vax.client._crypto._olmDevice.createOutboundSession( osborne2Device.deviceCurve25519Key, Object.values(otks)[0], ); const request = await secretStorage.request("foo", ["VAX"]); const secret = await request.promise; expect(secret).toBe("bar"); }); describe("bootstrap", function() { // keys used in some of the tests const XSK = new Uint8Array( olmlib.decodeBase64("3lo2YdJugHjfE+Or7KJ47NuKbhE7AAGLgQ/dc19913Q="), ); const XSPubKey = "DRb8pFVJyEJ9OWvXeUoM0jq/C2Wt+NxzBZVuk2nRb+0"; const USK = new Uint8Array( olmlib.decodeBase64("lKWi3hJGUie5xxHgySoz8PHFnZv6wvNaud/p2shN9VU="), ); const USPubKey = "CUpoiTtHiyXpUmd+3ohb7JVxAlUaOG1NYs9Jlx8soQU"; const SSK = new Uint8Array( olmlib.decodeBase64("1R6JVlXX99UcfUZzKuCDGQgJTw8ur1/ofgPD8pp+96M="), ); const SSPubKey = "0DfNsRDzEvkCLA0gD3m7VAGJ5VClhjEsewI35xq873Q"; const SSSSKey = new Uint8Array( olmlib.decodeBase64( "XrmITOOdBhw6yY5Bh7trb/bgp1FRdIGyCUxxMP873R0=", ), ); it("bootstraps when no storage or cross-signing keys locally", async function() { const key = new Uint8Array(16); for (let i = 0; i < 16; i++) key[i] = i; const getKey = jest.fn(e => { return [Object.keys(e.keys)[0], key]; }); const bob = await makeTestClient( { userId: "@bob:example.com", deviceId: "bob1", }, { cryptoCallbacks: { getSecretStorageKey: getKey, }, }, ); bob.uploadDeviceSigningKeys = async () => {}; bob.uploadKeySignatures = async () => {}; bob.setAccountData = async function(eventType, contents, callback) { const event = new MatrixEvent({ type: eventType, content: contents, }); this.store.storeAccountDataEvents([ event, ]); this.emit("accountData", event); }; await bob.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async func => await func({}), }); await bob.bootstrapSecretStorage({ createSecretStorageKey, }); const crossSigning = bob._crypto._crossSigningInfo; const secretStorage = bob._crypto._secretStorage; expect(crossSigning.getId()).toBeTruthy(); expect(await crossSigning.isStoredInSecretStorage(secretStorage)) .toBeTruthy(); expect(await secretStorage.hasKey()).toBeTruthy(); }); it("bootstraps when cross-signing keys in secret storage", async function() { const decryption = new global.Olm.PkDecryption(); const storagePublicKey = decryption.generate_key(); const storagePrivateKey = decryption.get_private_key(); const bob = await makeTestClient( { userId: "@bob:example.com", deviceId: "bob1", }, { cryptoCallbacks: { getSecretStorageKey: async request => { const defaultKeyId = await bob.getDefaultSecretStorageKeyId(); expect(Object.keys(request.keys)).toEqual([defaultKeyId]); return [defaultKeyId, storagePrivateKey]; }, }, }, ); bob.uploadDeviceSigningKeys = async () => {}; bob.uploadKeySignatures = async () => {}; bob.setAccountData = async function(eventType, contents, callback) { const event = new MatrixEvent({ type: eventType, content: contents, }); this.store.storeAccountDataEvents([ event, ]); this.emit("accountData", event); }; bob._crypto.checkKeyBackup = async () => {}; const crossSigning = bob._crypto._crossSigningInfo; const secretStorage = bob._crypto._secretStorage; // Set up cross-signing keys from scratch with specific storage key await bob.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async func => await func({}), }); await bob.bootstrapSecretStorage({ createSecretStorageKey: async () => ({ // `pubkey` not used anymore with symmetric 4S keyInfo: { pubkey: storagePublicKey }, privateKey: storagePrivateKey, }), }); // Clear local cross-signing keys and read from secret storage bob._crypto._deviceList.storeCrossSigningForUser( "@bob:example.com", crossSigning.toStorage(), ); crossSigning.keys = {}; await bob.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async func => await func({}), }); expect(crossSigning.getId()).toBeTruthy(); expect(await crossSigning.isStoredInSecretStorage(secretStorage)) .toBeTruthy(); expect(await secretStorage.hasKey()).toBeTruthy(); }); it("adds passphrase checking if it's lacking", async function() { let crossSigningKeys = { master: XSK, user_signing: USK, self_signing: SSK, }; const secretStorageKeys = { key_id: SSSSKey, }; const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, { cryptoCallbacks: { getCrossSigningKey: t => crossSigningKeys[t], saveCrossSigningKeys: k => crossSigningKeys = k, getSecretStorageKey: ({keys}, name) => { for (const keyId of Object.keys(keys)) { if (secretStorageKeys[keyId]) { return [keyId, secretStorageKeys[keyId]]; } } }, }, }, ); alice.store.storeAccountDataEvents([ new MatrixEvent({ type: "m.secret_storage.default_key", content: { key: "key_id", }, }), new MatrixEvent({ type: "m.secret_storage.key.key_id", content: { algorithm: "m.secret_storage.v1.aes-hmac-sha2", passphrase: { algorithm: "m.pbkdf2", iterations: 500000, salt: "GbkvwKHVMveo1zGVSb2GMMdCinG2npJK", }, }, }), // we never use these values, other than checking that they // exist, so just use dummy values new MatrixEvent({ type: "m.cross_signing.master", content: { encrypted: { key_id: {ciphertext: "bla", mac: "bla", iv: "bla"}, }, }, }), new MatrixEvent({ type: "m.cross_signing.self_signing", content: { encrypted: { key_id: {ciphertext: "bla", mac: "bla", iv: "bla"}, }, }, }), new MatrixEvent({ type: "m.cross_signing.user_signing", content: { encrypted: { key_id: {ciphertext: "bla", mac: "bla", iv: "bla"}, }, }, }), ]); alice._crypto._deviceList.storeCrossSigningForUser("@alice:example.com", { keys: { master: { user_id: "@alice:example.com", usage: ["master"], keys: { [`ed25519:${XSPubKey}`]: XSPubKey, }, }, self_signing: sign({ user_id: "@alice:example.com", usage: ["self_signing"], keys: { [`ed25519:${SSPubKey}`]: SSPubKey, }, }, XSK, "@alice:example.com"), user_signing: sign({ user_id: "@alice:example.com", usage: ["user_signing"], keys: { [`ed25519:${USPubKey}`]: USPubKey, }, }, XSK, "@alice:example.com"), }, }); alice.getKeyBackupVersion = async () => { return { version: "1", algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", auth_data: sign({ public_key: "pxEXhg+4vdMf/kFwP4bVawFWdb0EmytL3eFJx++zQ0A", }, XSK, "@alice:example.com"), }; }; alice.setAccountData = async function(name, data) { const event = new MatrixEvent({ type: name, content: data, }); alice.store.storeAccountDataEvents([event]); this.emit("accountData", event); }; await alice.bootstrapSecretStorage(); expect(alice.getAccountData("m.secret_storage.default_key").getContent()) .toEqual({key: "key_id"}); const keyInfo = alice.getAccountData("m.secret_storage.key.key_id") .getContent(); expect(keyInfo.algorithm) .toEqual("m.secret_storage.v1.aes-hmac-sha2"); expect(keyInfo.passphrase).toEqual({ algorithm: "m.pbkdf2", iterations: 500000, salt: "GbkvwKHVMveo1zGVSb2GMMdCinG2npJK", }); expect(keyInfo).toHaveProperty("iv"); expect(keyInfo).toHaveProperty("mac"); expect(alice.checkSecretStorageKey(secretStorageKeys.key_id, keyInfo)) .toBeTruthy(); }); it("fixes backup keys in the wrong format", async function() { let crossSigningKeys = { master: XSK, user_signing: USK, self_signing: SSK, }; const secretStorageKeys = { key_id: SSSSKey, }; const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, { cryptoCallbacks: { getCrossSigningKey: t => crossSigningKeys[t], saveCrossSigningKeys: k => crossSigningKeys = k, getSecretStorageKey: ({keys}, name) => { for (const keyId of Object.keys(keys)) { if (secretStorageKeys[keyId]) { return [keyId, secretStorageKeys[keyId]]; } } }, }, }, ); alice.store.storeAccountDataEvents([ new MatrixEvent({ type: "m.secret_storage.default_key", content: { key: "key_id", }, }), new MatrixEvent({ type: "m.secret_storage.key.key_id", content: { algorithm: "m.secret_storage.v1.aes-hmac-sha2", passphrase: { algorithm: "m.pbkdf2", iterations: 500000, salt: "GbkvwKHVMveo1zGVSb2GMMdCinG2npJK", }, }, }), new MatrixEvent({ type: "m.cross_signing.master", content: { encrypted: { key_id: {ciphertext: "bla", mac: "bla", iv: "bla"}, }, }, }), new MatrixEvent({ type: "m.cross_signing.self_signing", content: { encrypted: { key_id: {ciphertext: "bla", mac: "bla", iv: "bla"}, }, }, }), new MatrixEvent({ type: "m.cross_signing.user_signing", content: { encrypted: { key_id: {ciphertext: "bla", mac: "bla", iv: "bla"}, }, }, }), new MatrixEvent({ type: "m.megolm_backup.v1", content: { encrypted: { key_id: await encryptAES( "123,45,6,7,89,1,234,56,78,90,12,34,5,67,8,90", secretStorageKeys.key_id, "m.megolm_backup.v1", ), }, }, }), ]); alice._crypto._deviceList.storeCrossSigningForUser("@alice:example.com", { keys: { master: { user_id: "@alice:example.com", usage: ["master"], keys: { [`ed25519:${XSPubKey}`]: XSPubKey, }, }, self_signing: sign({ user_id: "@alice:example.com", usage: ["self_signing"], keys: { [`ed25519:${SSPubKey}`]: SSPubKey, }, }, XSK, "@alice:example.com"), user_signing: sign({ user_id: "@alice:example.com", usage: ["user_signing"], keys: { [`ed25519:${USPubKey}`]: USPubKey, }, }, XSK, "@alice:example.com"), }, }); alice.getKeyBackupVersion = async () => { return { version: "1", algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", auth_data: sign({ public_key: "pxEXhg+4vdMf/kFwP4bVawFWdb0EmytL3eFJx++zQ0A", }, XSK, "@alice:example.com"), }; }; alice.setAccountData = async function(name, data) { const event = new MatrixEvent({ type: name, content: data, }); alice.store.storeAccountDataEvents([event]); this.emit("accountData", event); }; await alice.bootstrapSecretStorage(); const backupKey = alice.getAccountData("m.megolm_backup.v1") .getContent(); expect(backupKey.encrypted).toHaveProperty("key_id"); expect(await alice.getSecret("m.megolm_backup.v1")) .toEqual("ey0GB1kB6jhOWgwiBUMIWg=="); }); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/verification/000077500000000000000000000000001403504662500222175ustar00rootroot00000000000000matrix-js-sdk-9.11.0/spec/unit/crypto/verification/InRoomChannel.spec.js000066400000000000000000000074421403504662500262110ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {InRoomChannel} from "../../../../src/crypto/verification/request/InRoomChannel"; "../../../../src/crypto/verification/request/ToDeviceChannel"; import {MatrixEvent} from "../../../../src/models/event"; describe("InRoomChannel tests", function() { const ALICE = "@alice:hs.tld"; const BOB = "@bob:hs.tld"; const MALORY = "@malory:hs.tld"; const client = { getUserId() { return ALICE; }, }; it("getEventType only returns .request for a message with a msgtype", function() { const invalidEvent = new MatrixEvent({ type: "m.key.verification.request", }); expect(InRoomChannel.getEventType(invalidEvent)).toStrictEqual(""); const validEvent = new MatrixEvent({ type: "m.room.message", content: { msgtype: "m.key.verification.request" }, }); expect(InRoomChannel.getEventType(validEvent)). toStrictEqual("m.key.verification.request"); const validFooEvent = new MatrixEvent({ type: "m.foo" }); expect(InRoomChannel.getEventType(validFooEvent)). toStrictEqual("m.foo"); }); it("getEventType should return m.room.message for messages", function() { const messageEvent = new MatrixEvent({ type: "m.room.message", content: { msgtype: "m.text" }, }); // XXX: The event type doesn't matter too much, just as long as it's not a verification event expect(InRoomChannel.getEventType(messageEvent)). toStrictEqual("m.room.message"); }); it("getEventType should return actual type for non-message events", function() { const event = new MatrixEvent({ type: "m.room.member", content: { }, }); expect(InRoomChannel.getEventType(event)). toStrictEqual("m.room.member"); }); it("getOtherPartyUserId should not return anything for a request not " + "directed at me", function() { const event = new MatrixEvent({ sender: BOB, type: "m.room.message", content: { msgtype: "m.key.verification.request", to: MALORY }, }); expect(InRoomChannel.getOtherPartyUserId(event, client)).toStrictEqual(undefined); }); it("getOtherPartyUserId should not return anything an event that is not of a valid " + "request type", function() { // invalid because this should be a room message with msgtype const invalidRequest = new MatrixEvent({ sender: BOB, type: "m.key.verification.request", content: { to: ALICE }, }); expect(InRoomChannel.getOtherPartyUserId(invalidRequest, client)) .toStrictEqual(undefined); const startEvent = new MatrixEvent({ sender: BOB, type: "m.key.verification.start", content: { to: ALICE }, }); expect(InRoomChannel.getOtherPartyUserId(startEvent, client)) .toStrictEqual(undefined); const fooEvent = new MatrixEvent({ sender: BOB, type: "m.foo", content: { to: ALICE }, }); expect(InRoomChannel.getOtherPartyUserId(fooEvent, client)) .toStrictEqual(undefined); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/verification/qr_code.spec.js000066400000000000000000000025711403504662500251270ustar00rootroot00000000000000/* 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. */ import "../../../olm-loader"; import {logger} from "../../../../src/logger"; const Olm = global.Olm; describe("QR code verification", function() { if (!global.Olm) { logger.warn('Not running device verification tests: libolm not present'); return; } beforeAll(function() { return Olm.init(); }); describe("reciprocate", () => { it("should verify the secret", () => { // TODO: Actually write a test for this. // Tests are hard because we are running before the verification // process actually begins, and are largely UI-driven rather than // logic-driven (compared to something like SAS). In the interest // of time, tests are currently excluded. }); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/verification/request.spec.js000066400000000000000000000054431403504662500252040ustar00rootroot00000000000000/* Copyright 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. */ import "../../../olm-loader"; import {verificationMethods} from "../../../../src/crypto"; import {logger} from "../../../../src/logger"; import {SAS} from "../../../../src/crypto/verification/SAS"; import {makeTestClients, setupWebcrypto, teardownWebcrypto} from './util'; const Olm = global.Olm; jest.useFakeTimers(); describe("verification request integration tests with crypto layer", function() { if (!global.Olm) { logger.warn('Not running device verification unit tests: libolm not present'); return; } beforeAll(function() { setupWebcrypto(); return Olm.init(); }); afterAll(() => { teardownWebcrypto(); }); 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.client._crypto._deviceList.getRawStoredDevicesForUser = function() { return { Dynabook: { keys: { "ed25519:Dynabook": "bob+base64+ed25519+key", }, }, }; }; alice.client.downloadKeys = () => { return Promise.resolve(); }; bob.client.downloadKeys = () => { return Promise.resolve(); }; bob.client.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 aliceRequest = await alice.client.requestVerification("@bob:example.com"); await aliceRequest.waitFor(r => r.started); const aliceVerifier = aliceRequest.verifier; expect(aliceVerifier).toBeInstanceOf(SAS); // XXX: Private function access (but it's a test, so we're okay) aliceVerifier._endTimer(); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/verification/sas.spec.js000066400000000000000000000452251403504662500243040ustar00rootroot00000000000000/* 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. */ import "../../../olm-loader"; import {makeTestClients, setupWebcrypto, teardownWebcrypto} from './util'; import {MatrixEvent} from "../../../../src/models/event"; import {SAS} from "../../../../src/crypto/verification/SAS"; import {DeviceInfo} from "../../../../src/crypto/deviceinfo"; import {verificationMethods} from "../../../../src/crypto"; import * as olmlib from "../../../../src/crypto/olmlib"; import {logger} from "../../../../src/logger"; import {resetCrossSigningKeys} from "../crypto-utils"; const Olm = global.Olm; let ALICE_DEVICES; let BOB_DEVICES; describe("SAS verification", function() { if (!global.Olm) { logger.warn('Not running device verification unit tests: libolm not present'); return; } beforeAll(function() { setupWebcrypto(); return Olm.init(); }); afterAll(() => { teardownWebcrypto(); }); it("should error on an unexpected event", async function() { //channel, baseApis, userId, deviceId, startEvent, request const request = { onVerifierCancelled: function() {}, }; const channel = { send: function() { return Promise.resolve(); }, }; const sas = new SAS(channel, {}, "@alice:example.com", "ABCDEFG", null, request); sas.handleEvent(new MatrixEvent({ sender: "@alice:example.com", type: "es.inquisition", content: {}, })); const spy = jest.fn(); await sas.verify().catch(spy); expect(spy).toHaveBeenCalled(); // Cancel the SAS for cleanup (we started a verification, so abort) sas.cancel(); }); describe("verification", () => { let alice; let bob; let aliceSasEvent; let bobSasEvent; let aliceVerifier; let bobPromise; beforeEach(async () => { [alice, bob] = await makeTestClients( [ {userId: "@alice:example.com", deviceId: "Osborne2"}, {userId: "@bob:example.com", deviceId: "Dynabook"}, ], { verificationMethods: [verificationMethods.SAS], }, ); const aliceDevice = alice.client._crypto._olmDevice; const bobDevice = bob.client._crypto._olmDevice; ALICE_DEVICES = { Osborne2: { user_id: "@alice:example.com", device_id: "Osborne2", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { "ed25519:Osborne2": aliceDevice.deviceEd25519Key, "curve25519:Osborne2": aliceDevice.deviceCurve25519Key, }, }, }; BOB_DEVICES = { Dynabook: { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], keys: { "ed25519:Dynabook": bobDevice.deviceEd25519Key, "curve25519:Dynabook": bobDevice.deviceCurve25519Key, }, }, }; alice.client._crypto._deviceList.storeDevicesForUser( "@bob:example.com", BOB_DEVICES, ); alice.client.downloadKeys = () => { return Promise.resolve(); }; bob.client._crypto._deviceList.storeDevicesForUser( "@alice:example.com", ALICE_DEVICES, ); bob.client.downloadKeys = () => { return Promise.resolve(); }; aliceSasEvent = null; bobSasEvent = null; bobPromise = new Promise((resolve, reject) => { bob.client.on("crypto.verification.request", request => { request.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(request.verifier); }); }); aliceVerifier = alice.client.beginKeyVerification( verificationMethods.SAS, bob.client.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(); } } }); }); afterEach(async () => { await Promise.all([ alice.stop(), bob.stop(), ]); }); it("should verify a key", async () => { let macMethod; let keyAgreement; const origSendToDevice = bob.client.sendToDevice.bind(bob.client); bob.client.sendToDevice = function(type, map) { if (type === "m.key.verification.accept") { macMethod = map[alice.client.getUserId()][alice.client.deviceId] .message_authentication_code; keyAgreement = map[alice.client.getUserId()][alice.client.deviceId] .key_agreement_protocol; } return origSendToDevice(type, map); }; alice.httpBackend.when('POST', '/keys/query').respond(200, { failures: {}, device_keys: { "@bob:example.com": BOB_DEVICES, }, }); bob.httpBackend.when('POST', '/keys/query').respond(200, { failures: {}, device_keys: { "@alice:example.com": ALICE_DEVICES, }, }); await Promise.all([ aliceVerifier.verify(), bobPromise.then((verifier) => verifier.verify()), alice.httpBackend.flush(), bob.httpBackend.flush(), ]); // 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 const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); expect(bobDevice.isVerified()).toBeTruthy(); const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); expect(aliceDevice.isVerified()).toBeTruthy(); }); it("should be able to verify using the old MAC", async () => { // pretend that Alice can only understand the old (incorrect) MAC, // and make sure that she can still verify with Bob let macMethod; const aliceOrigSendToDevice = alice.client.sendToDevice.bind(alice.client); alice.client.sendToDevice = (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.client.getUserId()][bob.client.deviceId] .message_authentication_codes = ['hmac-sha256']; } return aliceOrigSendToDevice(type, map); }; const bobOrigSendToDevice = bob.client.sendToDevice.bind(bob.client); bob.client.sendToDevice = (type, map) => { if (type === "m.key.verification.accept") { macMethod = map[alice.client.getUserId()][alice.client.deviceId] .message_authentication_code; } return bobOrigSendToDevice(type, map); }; alice.httpBackend.when('POST', '/keys/query').respond(200, { failures: {}, device_keys: { "@bob:example.com": BOB_DEVICES, }, }); bob.httpBackend.when('POST', '/keys/query').respond(200, { failures: {}, device_keys: { "@alice:example.com": ALICE_DEVICES, }, }); await Promise.all([ aliceVerifier.verify(), bobPromise.then((verifier) => verifier.verify()), alice.httpBackend.flush(), bob.httpBackend.flush(), ]); expect(macMethod).toBe("hmac-sha256"); const bobDevice = await alice.client.getStoredDevice("@bob:example.com", "Dynabook"); expect(bobDevice.isVerified()).toBeTruthy(); const aliceDevice = await bob.client.getStoredDevice("@alice:example.com", "Osborne2"); expect(aliceDevice.isVerified()).toBeTruthy(); }); it("should verify a cross-signing key", async () => { alice.httpBackend.when('POST', '/keys/device_signing/upload').respond( 200, {}, ); alice.httpBackend.when('POST', '/keys/signatures/upload').respond(200, {}); alice.httpBackend.flush(undefined, 2); await resetCrossSigningKeys(alice.client); bob.httpBackend.when('POST', '/keys/device_signing/upload').respond(200, {}); bob.httpBackend.when('POST', '/keys/signatures/upload').respond(200, {}); bob.httpBackend.flush(undefined, 2); await resetCrossSigningKeys(bob.client); bob.client._crypto._deviceList.storeCrossSigningForUser( "@alice:example.com", { keys: alice.client._crypto._crossSigningInfo.keys, }, ); const verifyProm = Promise.all([ aliceVerifier.verify(), bobPromise.then((verifier) => { bob.httpBackend.when( 'POST', '/keys/signatures/upload', ).respond(200, {}); bob.httpBackend.flush(undefined, 1, 2000); return verifier.verify(); }), ]); await verifyProm; const bobDeviceTrust = alice.client.checkDeviceTrust( "@bob:example.com", "Dynabook", ); expect(bobDeviceTrust.isLocallyVerified()).toBeTruthy(); expect(bobDeviceTrust.isCrossSigningVerified()).toBeFalsy(); const aliceTrust = bob.client.checkUserTrust("@alice:example.com"); expect(aliceTrust.isCrossSigningVerified()).toBeTruthy(); expect(aliceTrust.isTofu()).toBeTruthy(); const aliceDeviceTrust = bob.client.checkDeviceTrust( "@alice:example.com", "Osborne2", ); expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy(); expect(aliceDeviceTrust.isCrossSigningVerified()).toBeFalsy(); }); }); 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.client.setDeviceVerified = jest.fn(); alice.client.downloadKeys = () => { return Promise.resolve(); }; bob.client.setDeviceVerified = jest.fn(); bob.client.downloadKeys = () => { return Promise.resolve(); }; const bobPromise = new Promise((resolve, reject) => { bob.client.on("crypto.verification.request", request => { request.verifier.on("show_sas", (e) => { e.mismatch(); }); resolve(request.verifier); }); }); const aliceVerifier = alice.client.beginKeyVerification( verificationMethods.SAS, bob.client.getUserId(), bob.client.deviceId, ); const aliceSpy = jest.fn(); const bobSpy = jest.fn(); await Promise.all([ aliceVerifier.verify().catch(aliceSpy), bobPromise.then((verifier) => verifier.verify()).catch(bobSpy), ]); expect(aliceSpy).toHaveBeenCalled(); expect(bobSpy).toHaveBeenCalled(); expect(alice.client.setDeviceVerified) .not.toHaveBeenCalled(); expect(bob.client.setDeviceVerified) .not.toHaveBeenCalled(); }); describe("verification in DM", 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.client.setDeviceVerified = jest.fn(); alice.client.getDeviceEd25519Key = () => { return "alice+base64+ed25519+key"; }; alice.client.getStoredDevice = () => { return DeviceInfo.fromStorage( { keys: { "ed25519:Dynabook": "bob+base64+ed25519+key", }, }, "Dynabook", ); }; alice.client.downloadKeys = () => { return Promise.resolve(); }; bob.client.setDeviceVerified = jest.fn(); bob.client.getStoredDevice = () => { return DeviceInfo.fromStorage( { keys: { "ed25519:Osborne2": "alice+base64+ed25519+key", }, }, "Osborne2", ); }; bob.client.getDeviceEd25519Key = () => { return "bob+base64+ed25519+key"; }; bob.client.downloadKeys = () => { return Promise.resolve(); }; aliceSasEvent = null; bobSasEvent = null; bobPromise = new Promise((resolve, reject) => { bob.client.on("crypto.verification.request", async (request) => { const verifier = request.beginKeyVerification(SAS.NAME); 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(); } } }); await verifier.verify(); resolve(); }); }); const aliceRequest = await alice.client.requestVerificationDM( bob.client.getUserId(), "!room_id", ); await aliceRequest.waitFor(r => r.started); aliceVerifier = aliceRequest.verifier; 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(); } } }); }); afterEach(async function() { await Promise.all([ alice.stop(), bob.stop(), ]); }); it("should verify a key", async function() { await Promise.all([ aliceVerifier.verify(), bobPromise, ]); // make sure Alice and Bob verified each other expect(alice.client.setDeviceVerified) .toHaveBeenCalledWith(bob.client.getUserId(), bob.client.deviceId); expect(bob.client.setDeviceVerified) .toHaveBeenCalledWith(alice.client.getUserId(), alice.client.deviceId); }); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/verification/secret_request.spec.js000066400000000000000000000075711403504662500265550ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {VerificationBase} from '../../../../src/crypto/verification/Base'; import {CrossSigningInfo} from '../../../../src/crypto/CrossSigning'; import {encodeBase64} from "../../../../src/crypto/olmlib"; import {setupWebcrypto, teardownWebcrypto} from './util'; jest.useFakeTimers(); // Private key for tests only const testKey = new Uint8Array([ 0xda, 0x5a, 0x27, 0x60, 0xe3, 0x3a, 0xc5, 0x82, 0x9d, 0x12, 0xc3, 0xbe, 0xe8, 0xaa, 0xc2, 0xef, 0xae, 0xb1, 0x05, 0xc1, 0xe7, 0x62, 0x78, 0xa6, 0xd7, 0x1f, 0xf8, 0x2c, 0x51, 0x85, 0xf0, 0x1d, ]); const testKeyPub = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"; describe("self-verifications", () => { beforeAll(function() { setupWebcrypto(); return global.Olm.init(); }); afterAll(() => { teardownWebcrypto(); }); it("triggers a request for key sharing upon completion", async () => { const userId = "@test:localhost"; const cacheCallbacks = { getCrossSigningKeyCache: jest.fn().mockReturnValue(null), storeCrossSigningKeyCache: jest.fn(), }; const _crossSigningInfo = new CrossSigningInfo( userId, {}, cacheCallbacks, ); _crossSigningInfo.keys = { master: { keys: { X: testKeyPub } }, self_signing: { keys: { X: testKeyPub } }, user_signing: { keys: { X: testKeyPub } }, }; const _secretStorage = { request: jest.fn().mockReturnValue({ promise: Promise.resolve(encodeBase64(testKey)), }), }; const storeSessionBackupPrivateKey = jest.fn(); const restoreKeyBackupWithCache = jest.fn(() => Promise.resolve()); const client = { _crypto: { _crossSigningInfo, _secretStorage, storeSessionBackupPrivateKey, getSessionBackupPrivateKey: () => null, }, requestSecret: _secretStorage.request.bind(_secretStorage), getUserId: () => userId, getKeyBackupVersion: () => Promise.resolve({}), restoreKeyBackupWithCache, }; const request = { onVerifierFinished: () => undefined, }; const verification = new VerificationBase( undefined, // channel client, // baseApis userId, "ABC", // deviceId undefined, // startEvent request, ); verification._resolve = () => undefined; const result = await verification.done(); /* We should request, and store, 3 cross signing keys and the key backup key */ expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls.length).toBe(3); expect(_secretStorage.request.mock.calls.length).toBe(4); expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[0][1]) .toEqual(testKey); expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[1][1]) .toEqual(testKey); expect(storeSessionBackupPrivateKey.mock.calls[0][0]) .toEqual(testKey); expect(restoreKeyBackupWithCache).toHaveBeenCalled(); expect(result).toBeInstanceOf(Array); expect(result[0][0]).toBe(testKeyPub); expect(result[1][0]).toBe(testKeyPub); }); }); matrix-js-sdk-9.11.0/spec/unit/crypto/verification/util.js000066400000000000000000000104541403504662500235360ustar00rootroot00000000000000/* Copyright 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. */ import {TestClient} from '../../../TestClient'; import {MatrixEvent} from "../../../../src/models/event"; import nodeCrypto from "crypto"; import {logger} from '../../../../src/logger'; export async function makeTestClients(userInfos, options) { const clients = []; const clientMap = {}; const sendToDevice = function(type, map) { // logger.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, }); const client = clientMap[userId][deviceId]; const decryptionPromise = event.isEncrypted() ? event.attemptDecryption(client._crypto) : Promise.resolve(); decryptionPromise.then( () => client.emit("toDeviceEvent", event), ); } } } } }; const sendEvent = function(room, type, content) { // make up a unique ID as the event ID const eventId = "$" + this.makeTxnId(); // eslint-disable-line babel/no-invalid-this const rawEvent = { sender: this.getUserId(), // eslint-disable-line babel/no-invalid-this type: type, content: content, room_id: room, event_id: eventId, origin_server_ts: Date.now(), }; const event = new MatrixEvent(rawEvent); const remoteEcho = new MatrixEvent(Object.assign({}, rawEvent, { unsigned: { transaction_id: this.makeTxnId(), // eslint-disable-line babel/no-invalid-this }, })); setImmediate(() => { for (const tc of clients) { if (tc.client === this) { // eslint-disable-line babel/no-invalid-this logger.log("sending remote echo!!"); tc.client.emit("Room.timeline", remoteEcho); } else { tc.client.emit("Room.timeline", event); } } }); return Promise.resolve({event_id: eventId}); }; for (const userInfo of userInfos) { let keys = {}; if (!options) options = {}; if (!options.cryptoCallbacks) options.cryptoCallbacks = {}; if (!options.cryptoCallbacks.saveCrossSigningKeys) { options.cryptoCallbacks.saveCrossSigningKeys = k => { keys = k; }; options.cryptoCallbacks.getCrossSigningKey = typ => keys[typ]; } const testClient = new TestClient( userInfo.userId, userInfo.deviceId, undefined, undefined, options, ); if (!(userInfo.userId in clientMap)) { clientMap[userInfo.userId] = {}; } clientMap[userInfo.userId][userInfo.deviceId] = testClient.client; testClient.client.sendToDevice = sendToDevice; testClient.client.sendEvent = sendEvent; clients.push(testClient); } await Promise.all(clients.map((testClient) => testClient.client.initCrypto())); return clients; } export function setupWebcrypto() { global.crypto = { getRandomValues: (buf) => { return nodeCrypto.randomFillSync(buf); }, }; } export function teardownWebcrypto() { global.crypto = undefined; } matrix-js-sdk-9.11.0/spec/unit/crypto/verification/verification_request.spec.js000066400000000000000000000271301403504662500277430ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {VerificationRequest, READY_TYPE, START_TYPE, DONE_TYPE} from "../../../../src/crypto/verification/request/VerificationRequest"; import {InRoomChannel} from "../../../../src/crypto/verification/request/InRoomChannel"; import {ToDeviceChannel} from "../../../../src/crypto/verification/request/ToDeviceChannel"; import {MatrixEvent} from "../../../../src/models/event"; import {setupWebcrypto, teardownWebcrypto} from "./util"; function makeMockClient(userId, deviceId) { let counter = 1; let events = []; const deviceEvents = {}; return { getUserId() { return userId; }, getDeviceId() { return deviceId; }, sendEvent(roomId, type, content) { counter = counter + 1; const eventId = `$${userId}-${deviceId}-${counter}`; events.push(new MatrixEvent({ sender: userId, event_id: eventId, room_id: roomId, type, content, origin_server_ts: Date.now(), })); return Promise.resolve({event_id: eventId}); }, sendToDevice(type, msgMap) { for (const userId of Object.keys(msgMap)) { const deviceMap = msgMap[userId]; for (const deviceId of Object.keys(deviceMap)) { const content = deviceMap[deviceId]; const event = new MatrixEvent({content, type}); deviceEvents[userId] = deviceEvents[userId] || {}; deviceEvents[userId][deviceId] = deviceEvents[userId][deviceId] || []; deviceEvents[userId][deviceId].push(event); } } return Promise.resolve(); }, popEvents() { const e = events; events = []; return e; }, popDeviceEvents(userId, deviceId) { const forDevice = deviceEvents[userId]; const events = forDevice && forDevice[deviceId]; const result = events || []; if (events) { delete forDevice[deviceId]; } return result; }, }; } const MOCK_METHOD = "mock-verify"; class MockVerifier { constructor(channel, client, userId, deviceId, startEvent) { this._channel = channel; this._startEvent = startEvent; } get events() { return [DONE_TYPE]; } async start() { if (this._startEvent) { await this._channel.send(DONE_TYPE, {}); } else { await this._channel.send(START_TYPE, {method: MOCK_METHOD}); } } async handleEvent(event) { if (event.getType() === DONE_TYPE && !this._startEvent) { await this._channel.send(DONE_TYPE, {}); } } canSwitchStartEvent() { return false; } } function makeRemoteEcho(event) { return new MatrixEvent(Object.assign({}, event.event, { unsigned: { transaction_id: "abc", }, })); } async function distributeEvent(ownRequest, theirRequest, event) { await ownRequest.channel.handleEvent( makeRemoteEcho(event), ownRequest, true); await theirRequest.channel.handleEvent(event, theirRequest, true); } jest.useFakeTimers(); describe("verification request unit tests", function() { beforeAll(function() { setupWebcrypto(); }); afterAll(() => { teardownWebcrypto(); }); it("transition from UNSENT to DONE through happy path", async function() { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob = makeMockClient("@bob:matrix.tld", "device1"); const aliceRequest = new VerificationRequest( new InRoomChannel(alice, "!room", bob.getUserId()), new Map([[MOCK_METHOD, MockVerifier]]), alice); const bobRequest = new VerificationRequest( new InRoomChannel(bob, "!room"), new Map([[MOCK_METHOD, MockVerifier]]), bob); expect(aliceRequest.invalid).toBe(true); expect(bobRequest.invalid).toBe(true); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); expect(requestEvent.getType()).toBe("m.room.message"); await distributeEvent(aliceRequest, bobRequest, requestEvent); expect(aliceRequest.requested).toBe(true); expect(bobRequest.requested).toBe(true); await bobRequest.accept(); const [readyEvent] = bob.popEvents(); expect(readyEvent.getType()).toBe(READY_TYPE); await distributeEvent(bobRequest, aliceRequest, readyEvent); expect(bobRequest.ready).toBe(true); expect(aliceRequest.ready).toBe(true); const verifier = aliceRequest.beginKeyVerification(MOCK_METHOD); await verifier.start(); const [startEvent] = alice.popEvents(); expect(startEvent.getType()).toBe(START_TYPE); await distributeEvent(aliceRequest, bobRequest, startEvent); expect(aliceRequest.started).toBe(true); expect(aliceRequest.verifier).toBeInstanceOf(MockVerifier); expect(bobRequest.started).toBe(true); expect(bobRequest.verifier).toBeInstanceOf(MockVerifier); await bobRequest.verifier.start(); const [bobDoneEvent] = bob.popEvents(); expect(bobDoneEvent.getType()).toBe(DONE_TYPE); await distributeEvent(bobRequest, aliceRequest, bobDoneEvent); const [aliceDoneEvent] = alice.popEvents(); expect(aliceDoneEvent.getType()).toBe(DONE_TYPE); await distributeEvent(aliceRequest, bobRequest, aliceDoneEvent); expect(aliceRequest.done).toBe(true); expect(bobRequest.done).toBe(true); }); it("methods only contains common methods", async function() { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob = makeMockClient("@bob:matrix.tld", "device1"); const aliceRequest = new VerificationRequest( new InRoomChannel(alice, "!room", bob.getUserId()), new Map([["c", function() {}], ["a", function() {}]]), alice); const bobRequest = new VerificationRequest( new InRoomChannel(bob, "!room"), new Map([["c", function() {}], ["b", function() {}]]), bob); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); await distributeEvent(aliceRequest, bobRequest, requestEvent); await bobRequest.accept(); const [readyEvent] = bob.popEvents(); await distributeEvent(bobRequest, aliceRequest, readyEvent); expect(aliceRequest.methods).toStrictEqual(["c"]); expect(bobRequest.methods).toStrictEqual(["c"]); }); it("other client accepting request puts it in observeOnly mode", async function() { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob1 = makeMockClient("@bob:matrix.tld", "device1"); const bob2 = makeMockClient("@bob:matrix.tld", "device2"); const aliceRequest = new VerificationRequest( new InRoomChannel(alice, "!room", bob1.getUserId()), new Map(), alice); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); const bob1Request = new VerificationRequest( new InRoomChannel(bob1, "!room"), new Map(), bob1); const bob2Request = new VerificationRequest( new InRoomChannel(bob2, "!room"), new Map(), bob2); await bob1Request.channel.handleEvent(requestEvent, bob1Request, true); await bob2Request.channel.handleEvent(requestEvent, bob2Request, true); await bob1Request.accept(); const [readyEvent] = bob1.popEvents(); expect(bob2Request.observeOnly).toBe(false); await bob2Request.channel.handleEvent(readyEvent, bob2Request, true); expect(bob2Request.observeOnly).toBe(true); }); it("verify own device with to_device messages", async function() { const bob1 = makeMockClient("@bob:matrix.tld", "device1"); const bob2 = makeMockClient("@bob:matrix.tld", "device2"); const bob1Request = new VerificationRequest( new ToDeviceChannel(bob1, bob1.getUserId(), ["device1", "device2"], ToDeviceChannel.makeTransactionId(), "device2"), new Map([[MOCK_METHOD, MockVerifier]]), bob1); const to = {userId: "@bob:matrix.tld", deviceId: "device2"}; const verifier = bob1Request.beginKeyVerification(MOCK_METHOD, to); expect(verifier).toBeInstanceOf(MockVerifier); await verifier.start(); const [startEvent] = bob1.popDeviceEvents(to.userId, to.deviceId); expect(startEvent.getType()).toBe(START_TYPE); const bob2Request = new VerificationRequest( new ToDeviceChannel(bob2, bob2.getUserId(), ["device1"]), new Map([[MOCK_METHOD, MockVerifier]]), bob2); await bob2Request.channel.handleEvent(startEvent, bob2Request, true); await bob2Request.verifier.start(); const [doneEvent1] = bob2.popDeviceEvents("@bob:matrix.tld", "device1"); expect(doneEvent1.getType()).toBe(DONE_TYPE); await bob1Request.channel.handleEvent(doneEvent1, bob1Request, true); const [doneEvent2] = bob1.popDeviceEvents("@bob:matrix.tld", "device2"); expect(doneEvent2.getType()).toBe(DONE_TYPE); await bob2Request.channel.handleEvent(doneEvent2, bob2Request, true); expect(bob1Request.done).toBe(true); expect(bob2Request.done).toBe(true); }); it("request times out after 10 minutes", async function() { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob = makeMockClient("@bob:matrix.tld", "device1"); const aliceRequest = new VerificationRequest( new InRoomChannel(alice, "!room", bob.getUserId()), new Map(), alice); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); await aliceRequest.channel.handleEvent(requestEvent, aliceRequest, true, true, true); expect(aliceRequest.cancelled).toBe(false); expect(aliceRequest._cancellingUserId).toBe(undefined); jest.advanceTimersByTime(10 * 60 * 1000); expect(aliceRequest._cancellingUserId).toBe(alice.getUserId()); }); it("request times out 2 minutes after receipt", async function() { const alice = makeMockClient("@alice:matrix.tld", "device1"); const bob = makeMockClient("@bob:matrix.tld", "device1"); const aliceRequest = new VerificationRequest( new InRoomChannel(alice, "!room", bob.getUserId()), new Map(), alice); await aliceRequest.sendRequest(); const [requestEvent] = alice.popEvents(); const bobRequest = new VerificationRequest( new InRoomChannel(bob, "!room"), new Map(), bob); await bobRequest.channel.handleEvent(requestEvent, bobRequest, true); expect(bobRequest.cancelled).toBe(false); expect(bobRequest._cancellingUserId).toBe(undefined); jest.advanceTimersByTime(2 * 60 * 1000); expect(bobRequest._cancellingUserId).toBe(bob.getUserId()); }); }); matrix-js-sdk-9.11.0/spec/unit/event-timeline.spec.js000066400000000000000000000340371403504662500224400ustar00rootroot00000000000000import * as utils from "../test-utils"; import {EventTimeline} from "../../src/models/event-timeline"; import {RoomState} from "../../src/models/room-state"; function mockRoomStates(timeline) { timeline._startState = utils.mock(RoomState, "startState"); timeline._endState = utils.mock(RoomState, "endState"); } describe("EventTimeline", function() { const roomId = "!foo:bar"; const userA = "@alice:bar"; const userB = "@bertha:bar"; let timeline; beforeEach(function() { // 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); }).not.toThrow(); 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); }).not.toThrow(); expect(timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) .toBe(prev); expect(function() { timeline.setNeighbouringTimeline(prev, EventTimeline.BACKWARDS); }).toThrow(); expect(function() { timeline.setNeighbouringTimeline(next, EventTimeline.FORWARDS); }).not.toThrow(); 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 .mockImplementation(function(uid) { if (uid === userA) { return sentinel; } return null; }); timeline.getState(EventTimeline.BACKWARDS).getSentinelMember .mockImplementation(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 .mockImplementation(function(uid) { if (uid === userA) { return sentinel; } return null; }); timeline.getState(EventTimeline.BACKWARDS).getSentinelMember .mockImplementation(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). not.toHaveBeenCalled(); }); 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). not.toHaveBeenCalled(); }); }); 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); }); }); }); matrix-js-sdk-9.11.0/spec/unit/event.spec.js000066400000000000000000000051331403504662500206270ustar00rootroot00000000000000/* Copyright 2017 New Vector Ltd Copyright 2019 The Matrix.org Foundaction C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {logger} from "../../src/logger"; import {MatrixEvent} from "../../src/models/event"; describe("MatrixEvent", () => { 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; let prom2Fulfilled = false; 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); prom2.then(() => prom2Fulfilled = true); const error = new Error("nope"); error.name = 'DecryptionError'; return Promise.reject(error); } else { expect(prom2Fulfilled).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; }); }); }); }); matrix-js-sdk-9.11.0/spec/unit/filter-component.spec.js000066400000000000000000000020621403504662500227710ustar00rootroot00000000000000import {FilterComponent} from "../../src/filter-component"; import {mkEvent} from '../test-utils'; describe("Filter Component", function() { describe("types", function() { it("should filter out events with other types", function() { const filter = new FilterComponent({ types: ['m.room.message'] }); const event = mkEvent({ type: 'm.room.member', content: { }, room: 'roomId', event: true, }); const checkResult = filter.check(event); expect(checkResult).toBe(false); }); it("should validate events with the same type", function() { const filter = new FilterComponent({ types: ['m.room.message'] }); const event = mkEvent({ type: 'm.room.message', content: { }, room: 'roomId', event: true, }); const checkResult = filter.check(event); expect(checkResult).toBe(true); }); }); }); matrix-js-sdk-9.11.0/spec/unit/filter.spec.js000066400000000000000000000026561403504662500210020ustar00rootroot00000000000000import {Filter} from "../../src/filter"; describe("Filter", function() { const filterId = "f1lt3ring15g00d4ursoul"; const userId = "@sir_arthur_david:humming.tiger"; let filter; beforeEach(function() { 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); }); }); }); matrix-js-sdk-9.11.0/spec/unit/interactive-auth.spec.js000066400000000000000000000131621403504662500227630ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {logger} from "../../src/logger"; import {InteractiveAuth} from "../../src/interactive-auth"; import {MatrixError} from "../../src/http-api"; // Trivial client object to test interactive auth // (we do not need TestClient here) class FakeClient { generateClientSecret() { return "testcl1Ent5EcreT"; } } describe("InteractiveAuth", function() { it("should start an auth stage and complete it", function() { const doRequest = jest.fn(); const stateUpdated = jest.fn(); 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.mockImplementation(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.mockImplementation(function(authData) { logger.log('cccc'); expect(authData).toEqual({ session: "sessionId", type: "logintype", foo: "bar", }); return Promise.resolve(requestRes); }); return ia.attemptAuth().then(function(res) { expect(res).toBe(requestRes); expect(doRequest).toBeCalledTimes(1); expect(stateUpdated).toBeCalledTimes(1); }); }); it("should make a request if no authdata is provided", function() { const doRequest = jest.fn(); const stateUpdated = jest.fn(); 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.mockImplementation(function(authData) { logger.log("request1", authData); expect(authData).toEqual(null); // first request should be null 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.mockImplementation(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.mockImplementation(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", }); }); return ia.attemptAuth().then(function(res) { expect(res).toBe(requestRes); expect(doRequest).toBeCalledTimes(2); expect(stateUpdated).toBeCalledTimes(1); }); }); it("should start an auth stage and reject if no auth flow", function() { const doRequest = jest.fn(); const stateUpdated = jest.fn(); const ia = new InteractiveAuth({ matrixClient: new FakeClient(), doRequest: doRequest, stateUpdated: stateUpdated, }); doRequest.mockImplementation(function(authData) { logger.log("request1", authData); expect(authData).toEqual(null); // first request should be null const err = new MatrixError({ session: "sessionId", flows: [], params: { "logintype": { param: "aa" }, }, }); err.httpStatus = 401; throw err; }); return ia.attemptAuth().catch(function(error) { expect(error.message).toBe('No appropriate authentication flow found'); }); }); }); matrix-js-sdk-9.11.0/spec/unit/login.spec.js000066400000000000000000000013771403504662500206240ustar00rootroot00000000000000import {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); }); }); matrix-js-sdk-9.11.0/spec/unit/matrix-client.spec.js000066400000000000000000000512351403504662500222720ustar00rootroot00000000000000import {logger} from "../../src/logger"; import {MatrixClient} from "../../src/client"; import {Filter} from "../../src/filter"; jest.useFakeTimers(); describe("MatrixClient", function() { const userId = "@alice:bar"; const identityServerUrl = "https://identity.server"; const identityServerDomain = "identity.server"; let client; let store; let scheduler; 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: new 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 new Promise(() => {}); } beforeEach(function() { scheduler = [ "getQueueForEvent", "queueEvent", "removeEventFromQueue", "setProcessFunction", ].reduce((r, k) => { r[k] = jest.fn(); 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] = jest.fn(); return r; }, {}); store.getSavedSync = jest.fn().mockReturnValue(Promise.resolve(null)); store.getSavedSyncToken = jest.fn().mockReturnValue(Promise.resolve(null)); store.setSyncData = jest.fn().mockReturnValue(Promise.resolve(null)); store.getClientOptions = jest.fn().mockReturnValue(Promise.resolve(null)); store.storeClientOptions = jest.fn().mockReturnValue(Promise.resolve(null)); store.isNewlyCreated = jest.fn().mockReturnValue(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", "getContentUri", "request", "uploadContent", ].reduce((r, k) => { r[k] = jest.fn(); return r; }, {}); client._http.authedRequest.mockImplementation(httpReq); client._http.request.mockImplementation(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() { // 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.mockImplementation(function() { return new 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.mockReturnValue(filterId); const filter = new Filter(0, filterId); filter.setDefinition({"room": {"timeline": {"limit": 8}}}); store.getFilter.mockReturnValue(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.mockReturnValue(invalidFilterId); const filterName = getFilterName(client.credentials.userId); client.store.setFilterIdByName(filterName, invalidFilterId); const filter = new 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); jest.advanceTimersByTime(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", ); jest.advanceTimersByTime(1); } else if (state === "RECONNECTING" && httpLookups.length > 0) { jest.advanceTimersByTime(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); jest.advanceTimersByTime(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 jest.advanceTimersByTime(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) { }); }); describe("getPresence", function() { it("should send a presence HTTP GET", function() { httpLookups = [{ method: "GET", path: `/presence/${encodeURIComponent(userId)}/status`, data: { "presence": "unavailable", "last_active_ago": 420845, }, }]; client.getPresence(userId); expect(httpLookups.length).toEqual(0); }); }); }); matrix-js-sdk-9.11.0/spec/unit/pushprocessor.spec.js000066400000000000000000000257011403504662500224300ustar00rootroot00000000000000import * as utils from "../test-utils"; import {PushProcessor} from "../../src/pushprocessor"; 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); }); }); matrix-js-sdk-9.11.0/spec/unit/realtime-callbacks.spec.js000066400000000000000000000135631403504662500232330ustar00rootroot00000000000000import * as callbacks from "../../src/realtime-callbacks"; let wallTime = 1234567890; jest.useFakeTimers(); describe("realtime-callbacks", function() { function tick(millis) { wallTime += millis; jest.advanceTimersByTime(millis); } beforeEach(function() { callbacks.setNow(() => wallTime); }); afterEach(function() { callbacks.setNow(); }); describe("setTimeout", function() { it("should call the callback after the timeout", function() { const callback = jest.fn(); callbacks.setTimeout(callback, 100); expect(callback).not.toHaveBeenCalled(); tick(100); expect(callback).toHaveBeenCalled(); }); it("should default to a zero timeout", function() { const callback = jest.fn(); callbacks.setTimeout(callback); expect(callback).not.toHaveBeenCalled(); tick(0); expect(callback).toHaveBeenCalled(); }); it("should pass any parameters to the callback", function() { const callback = jest.fn(); 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 = jest.fn(); callbacks.setTimeout(callback, 2000); expect(callback).not.toHaveBeenCalled(); for (let i = 0; i < 4; i++) { tick(500); } expect(callback).toHaveBeenCalled(); }); it("should call multiple callbacks in the right order", function() { const callback1 = jest.fn(); const callback2 = jest.fn(); const callback3 = jest.fn(); callbacks.setTimeout(callback2, 200); callbacks.setTimeout(callback1, 100); callbacks.setTimeout(callback3, 300); expect(callback1).not.toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); expect(callback3).not.toHaveBeenCalled(); tick(100); expect(callback1).toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); expect(callback3).not.toHaveBeenCalled(); tick(100); expect(callback1).toHaveBeenCalled(); expect(callback2).toHaveBeenCalled(); expect(callback3).not.toHaveBeenCalled(); 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 = jest.fn(); const callback2 = jest.fn(); // check that cb1 is called before cb2 callback1.mockImplementation(function() { expect(callback2).not.toHaveBeenCalled(); }); callbacks.setTimeout(callback1); callbacks.setTimeout(callback2, -100); expect(callback1).not.toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); tick(0); expect(callback1).toHaveBeenCalled(); expect(callback2).toHaveBeenCalled(); }); it("should not get confused by chained calls", function() { const callback2 = jest.fn(); const callback1 = jest.fn(function() { callbacks.setTimeout(callback2, 0); expect(callback2).not.toHaveBeenCalled(); }); callbacks.setTimeout(callback1); expect(callback1).not.toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); 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 = jest.fn(function() { throw new Error("prepare to die"); }); const callback2 = jest.fn(); callbacks.setTimeout(callback1, 0); callbacks.setTimeout(callback2, 0); expect(callback1).not.toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); tick(0); expect(callback1).toHaveBeenCalled(); expect(callback2).toHaveBeenCalled(); }); }); describe("cancelTimeout", function() { it("should cancel a pending timeout", function() { const callback = jest.fn(); const k = callbacks.setTimeout(callback); callbacks.clearTimeout(k); tick(0); expect(callback).not.toHaveBeenCalled(); }); it("should not affect sooner timeouts", function() { const callback1 = jest.fn(); const callback2 = jest.fn(); callbacks.setTimeout(callback1, 100); const k = callbacks.setTimeout(callback2, 200); callbacks.clearTimeout(k); tick(100); expect(callback1).toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); tick(150); expect(callback2).not.toHaveBeenCalled(); }); }); }); matrix-js-sdk-9.11.0/spec/unit/room-member.spec.js000066400000000000000000000266511403504662500217370ustar00rootroot00000000000000import * as utils from "../test-utils"; import {RoomMember} from "../../src/models/room-member"; describe("RoomMember", function() { const roomId = "!foo:bar"; const userA = "@alice:bar"; const userB = "@bertha:bar"; const userC = "@clarissa:bar"; let member; beforeEach(function() { 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")).not.toEqual(-1); }); 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).not.toEqual("Alice"); // it should disambig. // user_id should be there somewhere expect(member.name.indexOf(userA)).not.toEqual(-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).not.toEqual("Alíce"); // it should disambig. // user_id should be there somewhere expect(member.name.indexOf(userA)).not.toEqual(-1); }); }); }); matrix-js-sdk-9.11.0/spec/unit/room-state.spec.js000066400000000000000000000612401403504662500216010ustar00rootroot00000000000000import * as utils from "../test-utils"; import {RoomState} from "../../src/models/room-state"; import {RoomMember} from "../../src/models/room-member"; describe("RoomState", function() { const roomId = "!foo:bar"; const userA = "@alice:bar"; const userB = "@bob:bar"; const userC = "@cleo:bar"; const userLazy = "@lazy:bar"; let state; beforeEach(function() { 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)).not.toEqual(-1); expect([userA, userB].indexOf(members[1].userId)).not.toEqual(-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())).not.toEqual(-1); expect([userA, userB].indexOf(events[1].getStateKey())).not.toEqual(-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).not.toHaveBeenCalled(); 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()).not.toEqual(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); }); }); }); matrix-js-sdk-9.11.0/spec/unit/room.spec.js000066400000000000000000001601271403504662500204670ustar00rootroot00000000000000import * as utils from "../test-utils"; import {EventStatus, MatrixEvent} from "../../src/models/event"; import {EventTimeline} from "../../src/models/event-timeline"; import {RoomState} from "../../src/models/room-state"; import {Room} from "../../src/models/room"; 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() { room = new Room(roomId); // mock RoomStates room.oldState = room.getLiveTimeline()._startState = utils.mock(RoomState, "oldState"); room.currentState = room.getLiveTimeline()._endState = utils.mock(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.mockImplementation(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")).not.toEqual(-1); }); 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.mockImplementation(function(userId) { return { "@alice:bar": { userId: userA, roomId: roomId, }, }[userId] || null; }); }); 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)).not.toEqual(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).not.toHaveBeenCalled(); }); it("should synthesize read receipts for the senders of events", function() { const sentinel = { userId: userA, membership: "join", name: "Alice", }; room.currentState.getSentinelMember.mockImplementation(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.mockImplementation(function(uid) { if (uid === userA) { return sentinel; } return null; }); room.oldState.getSentinelMember.mockImplementation(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.mockImplementation(function(uid) { if (uid === userA) { return sentinel; } return null; }); room.oldState.getSentinelMember.mockImplementation(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).not.toHaveBeenCalled(); }); }); 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.mockImplementation(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.mockImplementation(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.mockImplementation(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.mockImplementation(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.mockImplementation(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.mockImplementation(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 setAltAliases = function(aliases) { room.addLiveEvents([utils.mkEvent({ type: "m.room.canonical_alias", room: roomId, skey: "", content: { alt_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)).not.toEqual(-1, name); expect(name.indexOf(userC)).not.toEqual(-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)).not.toEqual(-1, name); expect(name.indexOf(userC)).not.toEqual(-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)).not.toEqual(-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)).not.toEqual(-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)).not.toEqual(-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"); setAltAliases([alias, "#another:here"]); 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"); setAltAliases([alias, "#another:here"]); 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 = jest.fn(); 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 = jest.fn(); 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); }); }); }); matrix-js-sdk-9.11.0/spec/unit/scheduler.spec.js000066400000000000000000000302761403504662500214720ustar00rootroot00000000000000// This file had a function whose name is all caps, which displeases eslint /* eslint new-cap: "off" */ import {defer} from '../../src/utils'; import {MatrixError} from "../../src/http-api"; import {MatrixScheduler} from "../../src/scheduler"; import * as utils from "../test-utils"; jest.useFakeTimers(); describe("MatrixScheduler", function() { let scheduler; let retryFn; let queueFn; let deferred; 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() { 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; deferred = defer(); }); 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 = defer(); const deferB = 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 = 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 deferred.promise; } else if (procCount === 2) { // don't care about this deferred 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); deferred.reject({}); await retryDefer.promise; expect(procCount).toEqual(1); jest.advanceTimersByTime(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 = defer(); const deferB = 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 = 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 : deferred.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); jest.advanceTimersByTime(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 deferred.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 deferred.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); }); }); }); matrix-js-sdk-9.11.0/spec/unit/sync-accumulator.spec.js000066400000000000000000000360151403504662500230020ustar00rootroot00000000000000/* 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. */ import {SyncAccumulator} from "../../src/sync-accumulator"; // The event body & unsigned object get frozen to assert that they don't get altered // by the impl const RES_WITH_AGE = { next_batch: "abc", rooms: { invite: {}, leave: {}, join: { "!foo:bar": { account_data: { events: [] }, ephemeral: { events: [] }, unread_notifications: {}, timeline: { events: [ Object.freeze({ content: { body: "This thing is happening right now!", }, origin_server_ts: 123456789, sender: "@alice:localhost", type: "m.room.message", unsigned: Object.freeze({ age: 50, }), }), ], prev_batch: "something", }, }, }, }, }; describe("SyncAccumulator", function() { let sa; beforeEach(function() { 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"]); }); it("should return correctly adjusted age attributes", () => { const delta = 1000; const startingTs = 1000; const oldDateNow = Date.now; try { Date.now = jest.fn(); Date.now.mockReturnValue(startingTs); sa.accumulate(RES_WITH_AGE); Date.now.mockReturnValue(startingTs + delta); const output = sa.getJSON(); expect(output.roomsData.join["!foo:bar"].timeline.events[0].unsigned.age).toEqual( RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0].unsigned.age + delta, ); expect(Object.keys(output.roomsData.join["!foo:bar"].timeline.events[0])).toEqual( Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]), ); } finally { Date.now = oldDateNow; } }); it("should mangle age without adding extra keys", () => { sa.accumulate(RES_WITH_AGE); const output = sa.getJSON(); expect(Object.keys(output.roomsData.join["!foo:bar"].timeline.events[0])).toEqual( Object.keys(RES_WITH_AGE.rooms.join["!foo:bar"].timeline.events[0]), ); }); }); }); 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", }; } matrix-js-sdk-9.11.0/spec/unit/timeline-window.spec.js000066400000000000000000000445531403504662500226320ustar00rootroot00000000000000import {EventTimeline} from "../../src/models/event-timeline"; import {TimelineIndex, TimelineWindow} from "../../src/timeline-window"; import * as utils from "../test-utils"; 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() { 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 = {getTimelineForEvent: () => null}; client = {}; client.getEventTimeline = function(timelineSet0, eventId0) { expect(timelineSet0).toBe(timelineSet); return Promise.resolve(timeline); }; return new TimelineWindow(client, timelineSet, opts); } describe("load", function() { it("should initialise from the live timeline", function() { const liveTimeline = createTimeline(); const room = {}; room.getLiveTimeline = function() { return liveTimeline; }; const timelineWindow = new TimelineWindow(undefined, room); return timelineWindow.load(undefined, 2).then(function() { const expectedEvents = liveTimeline.getEvents().slice(1); expect(timelineWindow.getEvents()).toEqual(expectedEvents); }); }); it("should initialise from a specific event", function() { const timeline = createTimeline(); const eventId = timeline.getEvents()[1].getId(); const timelineSet = {getTimelineForEvent: () => null}; 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); return timelineWindow.load(eventId, 3).then(function() { const expectedEvents = timeline.getEvents(); expect(timelineWindow.getEvents()).toEqual(expectedEvents); }); }); it("canPaginate should return false until load has returned", function() { const timeline = createTimeline(); timeline.setPaginationToken("toktok1", EventTimeline.BACKWARDS); timeline.setPaginationToken("toktok2", EventTimeline.FORWARDS); const eventId = timeline.getEvents()[1].getId(); const timelineSet = {getTimelineForEvent: () => null}; 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); }; return 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); }); }); }); describe("pagination", function() { it("should be able to advance across the initial timeline", function() { const timeline = createTimeline(); const eventId = timeline.getEvents()[1].getId(); const timelineWindow = createWindow(timeline); return 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); }); }); it("should advance into next timeline", function() { const tls = createLinkedTimelines(); const eventId = tls[0].getEvents()[1].getId(); const timelineWindow = createWindow(tls[0], {windowLimit: 5}); return 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); }); }); it("should retreat into previous timeline", function() { const tls = createLinkedTimelines(); const eventId = tls[1].getEvents()[1].getId(); const timelineWindow = createWindow(tls[1], {windowLimit: 5}); return 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); }); }); it("should make forward pagination requests", function() { 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); }; return 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); }); }); it("should make backward pagination requests", function() { 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); }; return 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); }); }); it("should limit the number of unsuccessful pagination requests", function() { 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); }; return 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); }); }); }); }); matrix-js-sdk-9.11.0/spec/unit/user.spec.js000066400000000000000000000051471403504662500204710ustar00rootroot00000000000000import {User} from "../../src/models/user"; import * as utils from "../test-utils"; describe("User", function() { const userId = "@alice:bar"; let user; beforeEach(function() { 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); }); }); }); matrix-js-sdk-9.11.0/spec/unit/utils.spec.js000066400000000000000000000261021403504662500206450ustar00rootroot00000000000000import * as utils from "../../src/utils"; describe("utils", function() { describe("encodeParams", function() { it("should url encode and concat with &s", function() { const params = { foo: "bar", baz: "beer@", }; expect(utils.encodeParams(params)).toEqual( "foo=bar&baz=beer%40", ); }); }); describe("encodeUri", function() { it("should replace based on object keys and url encode", function() { const path = "foo/bar/%something/%here"; const vals = { "%something": "baz", "%here": "beer@", }; expect(utils.encodeUri(path, vals)).toEqual( "foo/bar/baz/beer%40", ); }); }); describe("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"]); }).not.toThrow(); }); }); describe("checkObjectHasNoAdditionalKeys", function() { it("should throw for extra keys", function() { expect(function() { utils.checkObjectHasNoAdditionalKeys({ foo: "bar", baz: 4, }, ["foo"]); }).toThrow(); expect(function() { utils.checkObjectHasNoAdditionalKeys({ foo: "bar", }, ["foo"]); }).not.toThrow(); }); }); describe("deepCompare", function() { const assert = { isTrue: function(x) { expect(x).toBe(true); }, isFalse: function(x) { expect(x).toBe(false); }, }; it("should handle primitives", function() { assert.isTrue(utils.deepCompare(null, null)); assert.isFalse(utils.deepCompare(null, undefined)); assert.isTrue(utils.deepCompare("hi", "hi")); assert.isTrue(utils.deepCompare(5, 5)); assert.isFalse(utils.deepCompare(5, 10)); }); it("should handle regexps", function() { assert.isTrue(utils.deepCompare(/abc/, /abc/)); assert.isFalse(utils.deepCompare(/abc/, /123/)); const r = /abc/; assert.isTrue(utils.deepCompare(r, r)); }); it("should handle dates", function() { assert.isTrue(utils.deepCompare(new Date("2011-03-31"), new Date("2011-03-31"))); assert.isFalse(utils.deepCompare(new Date("2011-03-31"), new Date("1970-01-01"))); }); it("should handle arrays", function() { assert.isTrue(utils.deepCompare([], [])); assert.isTrue(utils.deepCompare([1, 2], [1, 2])); assert.isFalse(utils.deepCompare([1, 2], [2, 1])); assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3])); }); it("should handle simple objects", function() { assert.isTrue(utils.deepCompare({}, {})); assert.isTrue(utils.deepCompare({a: 1, b: 2}, {a: 1, b: 2})); assert.isTrue(utils.deepCompare({a: 1, b: 2}, {b: 2, a: 1})); assert.isFalse(utils.deepCompare({a: 1, b: 2}, {a: 1, b: 3})); assert.isTrue(utils.deepCompare({1: {name: "mhc", age: 28}, 2: {name: "arb", age: 26}}, {1: {name: "mhc", age: 28}, 2: {name: "arb", age: 26}})); assert.isFalse(utils.deepCompare({1: {name: "mhc", age: 28}, 2: {name: "arb", age: 26}}, {1: {name: "mhc", age: 28}, 2: {name: "arb", age: 27}})); assert.isFalse(utils.deepCompare({}, null)); assert.isFalse(utils.deepCompare({}, undefined)); }); it("should handle functions", function() { // no two different function is equal really, they capture their // context variables so even if they have same toString(), they // won't have same functionality const func = function(x) { return true; }; const func2 = function(x) { return true; }; assert.isTrue(utils.deepCompare(func, func)); assert.isFalse(utils.deepCompare(func, func2)); assert.isTrue(utils.deepCompare({ a: { b: func } }, { a: { b: func } })); assert.isFalse(utils.deepCompare({ a: { b: func } }, { a: { b: func2 } })); }); }); describe("extend", function() { const SOURCE = { "prop2": 1, "string2": "x", "newprop": "new" }; it("should extend", function() { const target = { "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", }; const merged = { "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", "newprop": "new", }; const sourceOrig = JSON.stringify(SOURCE); utils.extend(target, SOURCE); expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); // check the originial wasn't modified expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); }); it("should ignore null", function() { const target = { "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", }; const merged = { "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", "newprop": "new", }; const sourceOrig = JSON.stringify(SOURCE); utils.extend(target, null, SOURCE); expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); // check the originial wasn't modified expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); }); it("should handle properties created with defineProperties", function() { const source = Object.defineProperties({}, { "enumerableProp": { get: function() { return true; }, enumerable: true, }, "nonenumerableProp": { get: function() { return true; }, }, }); const target = {}; utils.extend(target, source); expect(target.enumerableProp).toBe(true); expect(target.nonenumerableProp).toBe(undefined); }); }); describe("chunkPromises", function() { it("should execute promises in chunks", async function() { let promiseCount = 0; function fn1() { return new Promise(async function(resolve, reject) { await utils.sleep(1); expect(promiseCount).toEqual(0); ++promiseCount; resolve(); }); } function fn2() { return new Promise(function(resolve, reject) { expect(promiseCount).toEqual(1); ++promiseCount; resolve(); }); } await utils.chunkPromises([fn1, fn2], 1); expect(promiseCount).toEqual(2); }); }); }); matrix-js-sdk-9.11.0/spec/unit/webrtc/000077500000000000000000000000001403504662500175035ustar00rootroot00000000000000matrix-js-sdk-9.11.0/spec/unit/webrtc/call.spec.ts000066400000000000000000000204561403504662500217260ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {TestClient} from '../../TestClient'; import {MatrixCall, CallErrorCode} from '../../../src/webrtc/call'; const DUMMY_SDP = ( "v=0\r\n" + "o=- 5022425983810148698 2 IN IP4 127.0.0.1\r\n" + "s=-\r\nt=0 0\r\na=group:BUNDLE 0\r\n" + "a=msid-semantic: WMS h3wAi7s8QpiQMH14WG3BnDbmlOqo9I5ezGZA\r\n" + "m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126\r\n" + "c=IN IP4 0.0.0.0\r\na=rtcp:9 IN IP4 0.0.0.0\r\na=ice-ufrag:hLDR\r\n" + "a=ice-pwd:bMGD9aOldHWiI+6nAq/IIlRw\r\n" + "a=ice-options:trickle\r\n" + "a=fingerprint:sha-256 E4:94:84:F9:4A:98:8A:56:F5:5F:FD:AF:72:B9:32:89:49:5C:4B:9A:" + "4A:15:8E:41:8A:F3:69:E4:39:52:DC:D6\r\n" + "a=setup:active\r\n" + "a=mid:0\r\n" + "a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" + "a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n" + "a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n" + "a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n" + "a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n" + "a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n" + "a=sendrecv\r\n" + "a=msid:h3wAi7s8QpiQMH14WG3BnDbmlOqo9I5ezGZA 4357098f-3795-4131-bff4-9ba9c0348c49\r\n" + "a=rtcp-mux\r\n" + "a=rtpmap:111 opus/48000/2\r\n" + "a=rtcp-fb:111 transport-cc\r\n" + "a=fmtp:111 minptime=10;useinbandfec=1\r\n" + "a=rtpmap:103 ISAC/16000\r\n" + "a=rtpmap:104 ISAC/32000\r\n" + "a=rtpmap:9 G722/8000\r\n" + "a=rtpmap:0 PCMU/8000\r\n" + "a=rtpmap:8 PCMA/8000\r\n" + "a=rtpmap:106 CN/32000\r\n" + "a=rtpmap:105 CN/16000\r\n" + "a=rtpmap:13 CN/8000\r\n" + "a=rtpmap:110 telephone-event/48000\r\n" + "a=rtpmap:112 telephone-event/32000\r\n" + "a=rtpmap:113 telephone-event/16000\r\n" + "a=rtpmap:126 telephone-event/8000\r\n" + "a=ssrc:3619738545 cname:2RWtmqhXLdoF4sOi\r\n" ); class MockRTCPeerConnection { localDescription: RTCSessionDescription; constructor() { this.localDescription = { sdp: DUMMY_SDP, type: 'offer', toJSON: function() {}, }; } addEventListener() {} createOffer() { return Promise.resolve({}); } setRemoteDescription() { return Promise.resolve(); } setLocalDescription() { return Promise.resolve(); } close() {} getStats() { return []; } } describe('Call', function() { let client; let call; let prevNavigator; let prevDocument; let prevWindow; beforeEach(function() { prevNavigator = global.navigator; prevDocument = global.document; prevWindow = global.window; global.navigator = { mediaDevices: { // @ts-ignore Mock getUserMedia: () => { return { getTracks: () => [], getAudioTracks: () => [], getVideoTracks: () => [], }; }, }, }; global.window = { // @ts-ignore Mock RTCPeerConnection: MockRTCPeerConnection, // @ts-ignore Mock RTCSessionDescription: {}, // @ts-ignore Mock RTCIceCandidate: {}, getUserMedia: {}, }; // @ts-ignore Mock global.document = {}; client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}); // We just stub out sendEvent: we're not interested in testing the client's // event sending code here client.client.sendEvent = () => {}; client.httpBackend.when("GET", "/voip/turnServer").respond(200, {}); call = new MatrixCall({ client: client.client, roomId: '!foo:bar', }); // call checks one of these is wired up call.on('error', () => {}); }); afterEach(function() { client.stop(); global.navigator = prevNavigator; global.window = prevWindow; global.document = prevDocument; }); it('should ignore candidate events from non-matching party ID', async function() { const callPromise = call.placeVoiceCall(); await client.httpBackend.flush(); await callPromise; await call.onAnswerReceived({ getContent: () => { return { version: 1, call_id: call.callId, party_id: 'the_correct_party_id', answer: { sdp: DUMMY_SDP, }, }; }, }); call.peerConn.addIceCandidate = jest.fn(); call.onRemoteIceCandidatesReceived({ getContent: () => { return { version: 1, call_id: call.callId, party_id: 'the_correct_party_id', candidates: [ { candidate: '', sdpMid: '', }, ], }; }, }); expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(1); call.onRemoteIceCandidatesReceived({ getContent: () => { return { version: 1, call_id: call.callId, party_id: 'some_other_party_id', candidates: [ { candidate: '', sdpMid: '', }, ], }; }, }); expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(1); // Hangup to stop timers call.hangup(CallErrorCode.UserHangup, true); }); it('should add candidates received before answer if party ID is correct', async function() { const callPromise = call.placeVoiceCall(); await client.httpBackend.flush(); await callPromise; call.peerConn.addIceCandidate = jest.fn(); call.onRemoteIceCandidatesReceived({ getContent: () => { return { version: 1, call_id: call.callId, party_id: 'the_correct_party_id', candidates: [ { candidate: 'the_correct_candidate', sdpMid: '', }, ], }; }, }); call.onRemoteIceCandidatesReceived({ getContent: () => { return { version: 1, call_id: call.callId, party_id: 'some_other_party_id', candidates: [ { candidate: 'the_wrong_candidate', sdpMid: '', }, ], }; }, }); expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(0); await call.onAnswerReceived({ getContent: () => { return { version: 1, call_id: call.callId, party_id: 'the_correct_party_id', answer: { sdp: DUMMY_SDP, }, }; }, }); expect(call.peerConn.addIceCandidate.mock.calls.length).toBe(1); expect(call.peerConn.addIceCandidate).toHaveBeenCalledWith({ candidate: 'the_correct_candidate', sdpMid: '', }); }); }); matrix-js-sdk-9.11.0/src/000077500000000000000000000000001403504662500150735ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/@types/000077500000000000000000000000001403504662500163375ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/@types/event.ts000066400000000000000000000063431403504662500200360ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ export enum EventType { // Room state events RoomCanonicalAlias = "m.room.canonical_alias", RoomCreate = "m.room.create", RoomJoinRules = "m.room.join_rules", RoomMember = "m.room.member", RoomThirdPartyInvite = "m.room.third_party_invite", RoomPowerLevels = "m.room.power_levels", RoomName = "m.room.name", RoomTopic = "m.room.topic", RoomAvatar = "m.room.avatar", RoomPinnedEvents = "m.room.pinned_events", RoomEncryption = "m.room.encryption", RoomHistoryVisibility = "m.room.history_visibility", RoomGuestAccess = "m.room.guest_access", RoomServerAcl = "m.room.server_acl", RoomTombstone = "m.room.tombstone", /** * @deprecated Should not be used. */ RoomAliases = "m.room.aliases", // deprecated https://matrix.org/docs/spec/client_server/r0.6.1#historical-events // Spaces MSC1772 SpaceChild = "org.matrix.msc1772.space.child", SpaceParent = "org.matrix.msc1772.space.parent", // Room timeline events RoomRedaction = "m.room.redaction", RoomMessage = "m.room.message", RoomMessageEncrypted = "m.room.encrypted", Sticker = "m.sticker", CallInvite = "m.call.invite", CallCandidates = "m.call.candidates", CallAnswer = "m.call.answer", CallHangup = "m.call.hangup", CallReject = "m.call.reject", CallSelectAnswer = "m.call.select_answer", CallNegotiate = "m.call.negotiate", CallReplaces = "m.call.replaces", KeyVerificationRequest = "m.key.verification.request", KeyVerificationStart = "m.key.verification.start", KeyVerificationCancel = "m.key.verification.cancel", KeyVerificationMac = "m.key.verification.mac", // use of this is discouraged https://matrix.org/docs/spec/client_server/r0.6.1#m-room-message-feedback RoomMessageFeedback = "m.room.message.feedback", // Room ephemeral events Typing = "m.typing", Receipt = "m.receipt", Presence = "m.presence", // Room account_data events FullyRead = "m.fully_read", Tag = "m.tag", // User account_data events PushRules = "m.push_rules", Direct = "m.direct", IgnoredUserList = "m.ignored_user_list", // to_device events RoomKey = "m.room_key", RoomKeyRequest = "m.room_key_request", ForwardedRoomKey = "m.forwarded_room_key", Dummy = "m.dummy", } export enum MsgType { Text = "m.text", Emote = "m.emote", Notice = "m.notice", Image = "m.image", File = "m.file", Audio = "m.audio", Location = "m.location", Video = "m.video", } export const RoomCreateTypeField = "org.matrix.msc1772.type"; // Spaces MSC1772 export enum RoomType { Space = "org.matrix.msc1772.space", // Spaces MSC1772 } matrix-js-sdk-9.11.0/src/@types/global.d.ts000066400000000000000000000050551403504662500203760ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // this is needed to tell TS about global.Olm import * as Olm from "olm"; // eslint-disable-line @typescript-eslint/no-unused-vars export {}; declare global { namespace NodeJS { interface Global { localStorage: Storage; } } interface Window { electron?: Electron; } interface Electron { getDesktopCapturerSources(options: GetSourcesOptions): Promise>; } interface MediaDevices { // This is experimental and types don't know about it yet // https://github.com/microsoft/TypeScript/issues/33232 getDisplayMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise; getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise; } interface DesktopCapturerConstraints { audio: boolean | { mandatory: { chromeMediaSource: string; chromeMediaSourceId: string; }; }; video: boolean | { mandatory: { chromeMediaSource: string; chromeMediaSourceId: string; }; }; } interface DesktopCapturerSource { id: string; name: string; thumbnailURL: string; } interface GetSourcesOptions { types: Array; thumbnailSize?: { height: number; width: number; }; fetchWindowIcons?: boolean; } interface HTMLAudioElement { // sinkId & setSinkId are experimental and typescript doesn't know about them sinkId: string; setSinkId(outputId: string); } interface DummyInterfaceWeShouldntBeUsingThis {} interface Navigator { // We check for the webkit-prefixed getUserMedia to detect if we're // on webkit: we should check if we still need to do this webkitGetUserMedia: DummyInterfaceWeShouldntBeUsingThis; } } matrix-js-sdk-9.11.0/src/ReEmitter.ts000066400000000000000000000045331403504662500173500ustar00rootroot00000000000000/* 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. */ import { EventEmitter } from "events"; export class ReEmitter { private target: EventEmitter; constructor(target: EventEmitter) { this.target = target; } reEmit(source: EventEmitter, eventNames: string[]) { for (const eventName of 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 = (...args) => { // EventEmitter special cases 'error' to make the emit function throw if no // handler is attached, which sort of makes sense for making sure that something // handles an error, but for re-emitting, there could be a listener on the original // source object so the test doesn't really work. We *could* try to replicate the // same logic and throw if there is no listener on either the source or the target, // but this behaviour is fairly undesireable for us anyway: the main place we throw // 'error' events is for calls, where error events are usually emitted some time // later by a different part of the code where 'emit' throwing because the app hasn't // added an error handler isn't terribly helpful. (A better fix in retrospect may // have been to just avoid using the event name 'error', but backwards compat...) if (eventName === 'error' && this.target.listenerCount('error') === 0) return; this.target.emit(eventName, ...args, source); }; source.on(eventName, forSource); } } } matrix-js-sdk-9.11.0/src/autodiscovery.js000066400000000000000000000523561403504662500203440ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** @module auto-discovery */ 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. const failingClientConfig = { "m.homeserver": clientConfig["m.homeserver"], "m.identity_server": { state: AutoDiscovery.FAIL_PROMPT, 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); } /** * Gets the raw discovery client configuration for the given domain name. * Should only be used if there's no validation to be done on the resulting * object, otherwise use findClientConfig(). * @param {string} domain The domain to get the client config for. * @returns {Promise} Resolves to the domain's client config. Can * be an empty object. */ static async getRawClientConfig(domain) { if (!domain || typeof(domain) !== "string" || domain.length === 0) { throw new Error("'domain' must be a string of non-zero length"); } const response = await this._fetchWellKnownObject( `https://${domain}/.well-known/matrix/client`, ); if (!response) return {}; return response.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 && (response.statusCode < 200 || response.statusCode >= 300) ) { let action = "FAIL_PROMPT"; let reason = (err ? err.message : null) || "General failure"; if (response && 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, }); } }, ); }); } } matrix-js-sdk-9.11.0/src/base-apis.js000066400000000000000000002463271403504662500173130ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> 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. MatrixBaseApis is currently only meant to be used * by {@link client~MatrixClient}. * * @module base-apis */ import {SERVICE_TYPES} from './service-types'; import {logger} from './logger'; import {PushProcessor} from "./pushprocessor"; import * as utils from "./utils"; import {MatrixHttpApi, PREFIX_IDENTITY_V2, PREFIX_R0, PREFIX_UNSTABLE} from "./http-api"; function termsUrlForService(serviceType, baseUrl) { switch (serviceType) { case SERVICE_TYPES.IS: return baseUrl + PREFIX_IDENTITY_V2 + '/terms'; case SERVICE_TYPES.IM: return baseUrl + '/_matrix/integrations/v1/terms'; default: throw new Error('Unsupported service type'); } } /** * 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 {IdentityServerProvider} [opts.identityServer] * Optional. A provider object with one function `getAccessToken`, which is a * callback that returns a Promise of an identity access token to supply * with identity requests. If the object is unset, no access token will be * supplied. * See also https://github.com/vector-im/element-web/issues/10615 which seeks to * replace the previous approach of manual access tokens params with this * callback throughout the SDK. * * @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. */ export function MatrixBaseApis(opts) { utils.checkObjectHasKeys(opts, ["baseUrl", "request"]); this.baseUrl = opts.baseUrl; this.idBaseUrl = opts.idBaseUrl; this.identityServer = opts.identityServer; const httpOpts = { baseUrl: opts.baseUrl, idBaseUrl: opts.idBaseUrl, accessToken: opts.accessToken, request: opts.request, prefix: PREFIX_R0, onlyData: true, extraParams: opts.queryParams, localTimeoutMs: opts.localTimeoutMs, useAuthorizationHeader: opts.useAuthorizationHeader, }; this._http = new 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; }; /** * Set the Identity Server URL of this client * @param {string} url New Identity Server URL */ MatrixBaseApis.prototype.setIdentityServerUrl = function(url) { this.idBaseUrl = utils.ensureNoTrailingSlash(url); this._http.setIdBaseUrl(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 {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 {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 (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. * This method returns the auth info needed to create a new authenticated client, * Remember to call `setGuest(true)` on the (guest-)authenticated client, e.g: * ```javascript * const tmpClient = await sdk.createClient(MATRIX_INSTANCE); * const { user_id, device_id, access_token } = tmpClient.registerGuest(); * const client = createClient({ * baseUrl: MATRIX_INSTANCE, * accessToken: access_token, * userId: user_id, * deviceId: device_id, * }) * client.setGuest(true); * ``` * * @param {Object=} opts Registration options * @param {Object} opts.body JSON HTTP body to provide. * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: JSON object that contains: * { user_id, device_id, access_token, home_server } * @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 {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 {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 {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 {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 {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'. * @param {string} idpId The ID of the Identity Provider being targeted, optional. * @return {string} The HS URL to hit to begin the SSO login process. */ MatrixBaseApis.prototype.getSsoLoginUrl = function(redirectUrl, loginType, idpId) { if (loginType === undefined) { loginType = "sso"; } let prefix = PREFIX_R0; let url = "/login/" + loginType + "/redirect"; if (idpId) { url += "/" + idpId; prefix = "/_matrix/client/unstable/org.matrix.msc2858"; } return this._http.getUrl(url, { redirectUrl }, prefix); }; /** * @param {string} token Login token previously received from homeserver * @param {module:client.callback} callback Optional. * @return {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 {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 {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, }, 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 {Promise} Resolves: {room_id: {string}, * room_alias: {string(opt)}} * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.createRoom = async function(options, callback) { // some valid options include: room_alias_name, visibility, invite // inject the id_access_token if inviting 3rd party addresses const invitesNeedingToken = (options.invite_3pid || []) .filter(i => !i.id_access_token); if ( invitesNeedingToken.length > 0 && this.identityServer && this.identityServer.getAccessToken && await this.doesServerAcceptIdentityAccessToken() ) { const identityAccessToken = await this.identityServer.getAccessToken(); if (identityAccessToken) { for (const invite of invitesNeedingToken) { invite.id_access_token = identityAccessToken; } } } 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.authedRequest( undefined, "GET", path, null, null, { prefix: PREFIX_UNSTABLE, }, ); return response; }; /** * @param {string} roomId * @param {module:client.callback} callback Optional. * @return {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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) { const path = utils.encodeUri("/publicised_groups"); return this._http.authedRequest( undefined, "POST", path, undefined, { user_ids: userIds }, ); }; /** * @param {string} groupId * @param {bool} isPublic Whether the user's membership of this group is made public * @return {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 {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 {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 {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. * @param {object} opts Options for the read markers. * @param {object} opts.hidden True to hide the read receipt from other users. This * property is currently unstable and may change in the future. * @return {Promise} Resolves: the empty object, {}. */ MatrixBaseApis.prototype.setRoomReadMarkersHttpRequest = function(roomId, rmEventId, rrEventId, opts) { const path = utils.encodeUri("/rooms/$roomId/read_markers", { $roomId: roomId, }); const content = { "m.fully_read": rmEventId, "m.read": rrEventId, "m.hidden": Boolean(opts ? opts.hidden : false), }; return this._http.authedRequest( undefined, "POST", path, undefined, content, ); }; /** * @return {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 {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 {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 {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 {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, ); }; /** * @param {string} roomId * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: an object with an `aliases` property, containing an array of local aliases * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.unstableGetLocalAliases = function(roomId, callback) { const path = utils.encodeUri("/rooms/$roomId/aliases", {$roomId: roomId}); const prefix = PREFIX_UNSTABLE + "/org.matrix.msc2432"; return this._http.authedRequest(callback, "GET", path, null, null, { prefix }); }; /** * Get room info for the given alias. * @param {string} alias The room alias to resolve. * @param {module:client.callback} callback Optional. * @return {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 {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 {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 {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 {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 {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 {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 {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 {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 {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, ); }; /** * Add a 3PID to your homeserver account and optionally bind it to an identity * server as well. An identity server is required as part of the `creds` object. * * This API is deprecated, and you should instead use `addThreePidOnly` * for homeservers that support it. * * @param {Object} creds * @param {boolean} bind * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: on success * @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, ); }; /** * Add a 3PID to your homeserver account. This API does not use an identity * server, as the homeserver is expected to handle 3PID ownership validation. * * You can check whether a homeserver supports this API via * `doesServerSupportSeparateAddAndBind`. * * @param {Object} data A object with 3PID validation data from having called * `account/3pid//requestToken` on the homeserver. * @return {Promise} Resolves: on success * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.addThreePidOnly = async function(data) { const path = "/account/3pid/add"; const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE; return this._http.authedRequest( undefined, "POST", path, null, data, { prefix }, ); }; /** * Bind a 3PID for discovery onto an identity server via the homeserver. The * identity server handles 3PID ownership validation and the homeserver records * the new binding to track where all 3PIDs for the account are bound. * * You can check whether a homeserver supports this API via * `doesServerSupportSeparateAddAndBind`. * * @param {Object} data A object with 3PID validation data from having called * `validate//requestToken` on the identity server. It should also * contain `id_server` and `id_access_token` fields as well. * @return {Promise} Resolves: on success * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.bindThreePid = async function(data) { const path = "/account/3pid/bind"; const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE; return this._http.authedRequest( undefined, "POST", path, null, data, { prefix }, ); }; /** * Unbind a 3PID for discovery on an identity server via the homeserver. The * homeserver removes its record of the binding to keep an updated record of * where all 3PIDs for the account are bound. * * @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 {Promise} Resolves: on success * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.unbindThreePid = async function(medium, address) { const path = "/account/3pid/unbind"; const data = { medium, address, id_server: this.getIdentityServerUrl(true), }; const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE; return this._http.authedRequest( undefined, "POST", path, null, data, { prefix }, ); }; /** * @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 {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 {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 {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, ); }; /** * Gets specific device details for the logged-in user * @param {string} device_id device to query * @return {Promise} Resolves: result object * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.getDevice = function(device_id) { const path = utils.encodeUri("/devices/$device_id", { $device_id: device_id, }); return this._http.authedRequest( undefined, 'GET', path, undefined, undefined, ); }; /** * Update the given device * * @param {string} device_id device to update * @param {Object} body body of request * @return {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 {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 {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 {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 {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 {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/").then(rules => { return PushProcessor.rewriteDefaultRules(rules); }); }; /** * @param {string} scope * @param {string} kind * @param {string} ruleId * @param {Object} body * @param {module:client.callback} callback Optional. * @return {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 {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 {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 {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 {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 this method no longer takes any opts, * used to take opts.device_id but this was not removed from the spec as a redundant parameter * * @param {module:client.callback=} callback * * @return {Promise} Resolves: result object. Rejects: with * an error response ({@link module:http-api.MatrixError}). */ MatrixBaseApis.prototype.uploadKeysRequest = function(content, opts, callback) { return this._http.authedRequest(callback, "POST", "/keys/upload", undefined, content); }; MatrixBaseApis.prototype.uploadKeySignatures = function(content) { return this._http.authedRequest( undefined, "POST", '/keys/signatures/upload', undefined, content, { prefix: PREFIX_UNSTABLE, }, ); }; /** * 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 {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 * * @param {number} [timeout] the time (in milliseconds) to wait for keys from remote * servers * * @return {Promise} Resolves: result object. Rejects: with * an error response ({@link module:http-api.MatrixError}). */ MatrixBaseApis.prototype.claimOneTimeKeys = function(devices, key_algorithm, timeout) { 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}; if (timeout) { content.timeout = timeout; } 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 {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); }; MatrixBaseApis.prototype.uploadDeviceSigningKeys = function(auth, keys) { const data = Object.assign({}, keys); if (auth) Object.assign(data, {auth}); return this._http.authedRequest( undefined, "POST", "/keys/device_signing/upload", undefined, data, { prefix: PREFIX_UNSTABLE, }, ); }; // Identity Server Operations // ========================== /** * Register with an Identity Server using the OpenID token from the user's * Homeserver, which can be retrieved via * {@link module:client~MatrixClient#getOpenIdToken}. * * Note that the `/account/register` endpoint (as well as IS authentication in * general) was added as part of the v2 API version. * * @param {object} hsOpenIdToken * @return {Promise} Resolves: with object containing an Identity * Server access token. * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.registerWithIdentityServer = function(hsOpenIdToken) { if (!this.idBaseUrl) { throw new Error("No Identity Server base URL set"); } const uri = this.idBaseUrl + PREFIX_IDENTITY_V2 + "/account/register"; return this._http.requestOtherUrl( undefined, "POST", uri, null, hsOpenIdToken, ); }; /** * Requests an email verification token directly from an identity server. * * This API is used as part of binding an email for discovery on an identity * server. The validation data that results should be passed to the * `bindThreePid` method to complete the binding process. * * @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. * @param {string} identityAccessToken The `access_token` field of the identity * server `/account/register` response (see {@link registerWithIdentityServer}). * * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. * @throws Error if no identity server is set */ MatrixBaseApis.prototype.requestEmailToken = async function( email, clientSecret, sendAttempt, nextLink, callback, identityAccessToken, ) { const params = { client_secret: clientSecret, email: email, send_attempt: sendAttempt, next_link: nextLink, }; return await this._http.idServerRequest( callback, "POST", "/validate/email/requestToken", params, PREFIX_IDENTITY_V2, identityAccessToken, ); }; /** * Requests a MSISDN verification token directly from an identity server. * * This API is used as part of binding a MSISDN for discovery on an identity * server. The validation data that results should be passed to the * `bindThreePid` method to complete the binding process. * * @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 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 SMS. * To request another SMS 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. * @param {string} identityAccessToken The `access_token` field of the Identity * Server `/account/register` response (see {@link registerWithIdentityServer}). * * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. * @throws Error if no identity server is set */ MatrixBaseApis.prototype.requestMsisdnToken = async function( phoneCountry, phoneNumber, clientSecret, sendAttempt, nextLink, callback, identityAccessToken, ) { const params = { client_secret: clientSecret, country: phoneCountry, phone_number: phoneNumber, send_attempt: sendAttempt, next_link: nextLink, }; return await this._http.idServerRequest( callback, "POST", "/validate/msisdn/requestToken", params, PREFIX_IDENTITY_V2, identityAccessToken, ); }; /** * Submits a 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} msisdnToken The MSISDN token, as enetered by the user. * @param {string} identityAccessToken The `access_token` field of the Identity * Server `/account/register` response (see {@link registerWithIdentityServer}). * * @return {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 = async function( sid, clientSecret, msisdnToken, identityAccessToken, ) { const params = { sid: sid, client_secret: clientSecret, token: msisdnToken, }; return await this._http.idServerRequest( undefined, "POST", "/validate/msisdn/submitToken", params, PREFIX_IDENTITY_V2, identityAccessToken, ); }; /** * Submits a MSISDN token to an arbitrary URL. * * This is used when submitting the code sent by SMS to a phone number in the * newer 3PID flow where the homeserver validates 3PID ownership (as part of * `requestAdd3pidMsisdnToken`). The homeserver response may include a * `submit_url` to specify where the token should be sent, and this helper can * be used to pass the token to this URL. * * @param {string} url The URL to submit the token to * @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} msisdnToken The MSISDN token, as enetered by the user. * * @return {Promise} Resolves: Object, currently with no parameters. * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.submitMsisdnTokenOtherUrl = function( url, sid, clientSecret, msisdnToken, ) { const params = { sid: sid, client_secret: clientSecret, token: msisdnToken, }; return this._http.requestOtherUrl( undefined, "POST", url, undefined, params, ); }; /** * Gets the V2 hashing information from the identity server. Primarily useful for * lookups. * @param {string} identityAccessToken The access token for the identity server. * @returns {Promise} The hashing information for the identity server. */ MatrixBaseApis.prototype.getIdentityHashDetails = function(identityAccessToken) { return this._http.idServerRequest( undefined, "GET", "/hash_details", null, PREFIX_IDENTITY_V2, identityAccessToken, ); }; /** * Performs a hashed lookup of addresses against the identity server. This is * only supported on identity servers which have at least the version 2 API. * @param {Array>} addressPairs An array of 2 element arrays. * The first element of each pair is the address, the second is the 3PID medium. * Eg: ["email@example.org", "email"] * @param {string} identityAccessToken The access token for the identity server. * @returns {Promise>} A collection of address mappings to * found MXIDs. Results where no user could be found will not be listed. */ MatrixBaseApis.prototype.identityHashedLookup = async function( addressPairs, // [["email@example.org", "email"], ["10005550000", "msisdn"]] identityAccessToken, ) { const params = { // addresses: ["email@example.org", "10005550000"], // algorithm: "sha256", // pepper: "abc123" }; // Get hash information first before trying to do a lookup const hashes = await this.getIdentityHashDetails(identityAccessToken); if (!hashes || !hashes['lookup_pepper'] || !hashes['algorithms']) { throw new Error("Unsupported identity server: bad response"); } params['pepper'] = hashes['lookup_pepper']; const localMapping = { // hashed identifier => plain text address // For use in this function's return format }; // When picking an algorithm, we pick the hashed over no hashes if (hashes['algorithms'].includes('sha256')) { // Abuse the olm hashing const olmutil = new global.Olm.Utility(); params["addresses"] = addressPairs.map(p => { const addr = p[0].toLowerCase(); // lowercase to get consistent hashes const med = p[1].toLowerCase(); const hashed = olmutil.sha256(`${addr} ${med} ${params['pepper']}`) .replace(/\+/g, '-').replace(/\//g, '_'); // URL-safe base64 // Map the hash to a known (case-sensitive) address. We use the case // sensitive version because the caller might be expecting that. localMapping[hashed] = p[0]; return hashed; }); params["algorithm"] = "sha256"; } else if (hashes['algorithms'].includes('none')) { params["addresses"] = addressPairs.map(p => { const addr = p[0].toLowerCase(); // lowercase to get consistent hashes const med = p[1].toLowerCase(); const unhashed = `${addr} ${med}`; // Map the unhashed values to a known (case-sensitive) address. We use // the case sensitive version because the caller might be expecting that. localMapping[unhashed] = p[0]; return unhashed; }); params["algorithm"] = "none"; } else { throw new Error("Unsupported identity server: unknown hash algorithm"); } const response = await this._http.idServerRequest( undefined, "POST", "/lookup", params, PREFIX_IDENTITY_V2, identityAccessToken, ); if (!response || !response['mappings']) return []; // no results const foundAddresses = [/* {address: "plain@example.org", mxid} */]; for (const hashed of Object.keys(response['mappings'])) { const mxid = response['mappings'][hashed]; const plainAddress = localMapping[hashed]; if (!plainAddress) { throw new Error("Identity server returned more results than expected"); } foundAddresses.push({address: plainAddress, mxid}); } return foundAddresses; }; /** * 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. * @param {string} identityAccessToken The `access_token` field of the Identity * Server `/account/register` response (see {@link registerWithIdentityServer}). * * @return {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 = async function( medium, address, callback, identityAccessToken, ) { // Note: we're using the V2 API by calling this function, but our // function contract requires a V1 response. We therefore have to // convert it manually. const response = await this.identityHashedLookup( [[address, medium]], identityAccessToken, ); const result = response.find(p => p.address === address); if (!result) { if (callback) callback(null, {}); return {}; } const mapping = { address, medium, mxid: result.mxid, // We can't reasonably fill these parameters: // not_before // not_after // ts // signatures }; if (callback) callback(null, mapping); return mapping; }; /** * Looks up the public Matrix ID mappings for multiple 3PIDs. * * @param {Array.>} query Array of arrays containing * [medium, address] * @param {string} identityAccessToken The `access_token` field of the Identity * Server `/account/register` response (see {@link registerWithIdentityServer}). * * @return {Promise} Resolves: Lookup results from IS. * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.bulkLookupThreePids = async function( query, identityAccessToken, ) { // Note: we're using the V2 API by calling this function, but our // function contract requires a V1 response. We therefore have to // convert it manually. const response = await this.identityHashedLookup( // We have to reverse the query order to get [address, medium] pairs query.map(p => [p[1], p[0]]), identityAccessToken, ); const v1results = []; for (const mapping of response) { const originalQuery = query.find(p => p[1] === mapping.address); if (!originalQuery) { throw new Error("Identity sever returned unexpected results"); } v1results.push([ originalQuery[0], // medium mapping.address, mapping.mxid, ]); } return {threepids: v1results}; }; /** * Get account info from the Identity Server. This is useful as a neutral check * to verify that other APIs are likely to approve access by testing that the * token is valid, terms have been agreed, etc. * * @param {string} identityAccessToken The `access_token` field of the Identity * Server `/account/register` response (see {@link registerWithIdentityServer}). * * @return {Promise} Resolves: an object with account info. * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.getIdentityAccount = function( identityAccessToken, ) { return this._http.idServerRequest( undefined, "GET", "/account", undefined, PREFIX_IDENTITY_V2, identityAccessToken, ); }; // 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 {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, }; const targets = Object.keys(contentMap).reduce((obj, key) => { obj[key] = Object.keys(contentMap[key]); return obj; }, {}); logger.log(`PUT ${path}`, targets); 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 {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 {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 {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.prototype.getTerms = function(serviceType, baseUrl) { const url = termsUrlForService(serviceType, baseUrl); return this._http.requestOtherUrl( undefined, 'GET', url, ); }; MatrixBaseApis.prototype.agreeToTerms = function( serviceType, baseUrl, accessToken, termsUrls, ) { const url = termsUrlForService(serviceType, baseUrl); const headers = { Authorization: "Bearer " + accessToken, }; return this._http.requestOtherUrl( undefined, 'POST', url, null, { user_accepts: termsUrls }, { headers }, ); }; /** * Reports an event as inappropriate to the server, which may then notify the appropriate people. * @param {string} roomId The room in which the event being reported is located. * @param {string} eventId The event to report. * @param {number} score The score to rate this content as where -100 is most offensive and 0 is inoffensive. * @param {string} reason The reason the content is being reported. May be blank. * @returns {Promise} Resolves to an empty object if successful */ MatrixBaseApis.prototype.reportEvent = function(roomId, eventId, score, reason) { const path = utils.encodeUri("/rooms/$roomId/report/$eventId", { $roomId: roomId, $eventId: eventId, }); return this._http.authedRequest(undefined, "POST", path, null, {score, reason}); }; /** * Fetches or paginates a summary of a space as defined by MSC2946 * @param {string} roomId The ID of the space-room to use as the root of the summary. * @param {number?} maxRoomsPerSpace The maximum number of rooms to return per subspace. * @param {boolean?} suggestedOnly Whether to only return rooms with suggested=true. * @param {boolean?} autoJoinOnly Whether to only return rooms with auto_join=true. * @param {number?} limit The maximum number of rooms to return in total. * @param {string?} batch The opaque token to paginate a previous summary request. * @returns {Promise} the response, with next_batch, rooms, events fields. */ MatrixBaseApis.prototype.getSpaceSummary = function( roomId, maxRoomsPerSpace, suggestedOnly, autoJoinOnly, limit, batch, ) { const path = utils.encodeUri("/rooms/$roomId/spaces", { $roomId: roomId, }); return this._http.authedRequest(undefined, "POST", path, null, { max_rooms_per_space: maxRoomsPerSpace, suggested_only: suggestedOnly, auto_join_only: autoJoinOnly, limit, batch, }, { prefix: "/_matrix/client/unstable/org.matrix.msc2946", }); }; matrix-js-sdk-9.11.0/src/browser-index.js000066400000000000000000000035351403504662500202270ustar00rootroot00000000000000/* 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. */ import * as matrixcs from "./matrix"; import request from "browser-request"; import queryString from "qs"; matrixcs.request(function(opts, fn) { // We manually fix the query string for browser-request because // it doesn't correctly handle cases like ?via=one&via=two. Instead // we mimic `request`'s query string interface to make it all work // as expected. // browser-request will happily take the constructed string as the // query string without trying to modify it further. opts.qs = queryString.stringify(opts.qs || {}, opts.qsStringifyOptions); return request(opts, fn); }); // just *accessing* indexedDB throws an exception in firefox with // indexeddb disabled. let indexedDB; try { indexedDB = global.indexedDB; } catch (e) {} // if our browser (appears to) support indexeddb, use an indexeddb crypto store. if (indexedDB) { matrixcs.setCryptoStoreFactory( function() { return new matrixcs.IndexedDBCryptoStore( indexedDB, "matrix-js-sdk:crypto", ); }, ); } // We export 3 things to make browserify happy as well as downstream projects. // It's awkward, but required. export * from "./matrix"; export default matrixcs; // keep export for browserify package deps global.matrixcs = matrixcs; matrix-js-sdk-9.11.0/src/client.js000066400000000000000000006467511403504662500167320ustar00rootroot00000000000000/* 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. */ /** * This is an internal module. See {@link MatrixClient} for the public class. * @module client */ import url from "url"; import {EventEmitter} from "events"; import {MatrixBaseApis} from "./base-apis"; import {Filter} from "./filter"; import {SyncApi} from "./sync"; import {EventStatus, MatrixEvent} from "./models/event"; import {EventTimeline} from "./models/event-timeline"; import {SearchResult} from "./models/search-result"; import {StubStore} from "./store/stub"; import {createNewMatrixCall} from "./webrtc/call"; import {CallEventHandler} from './webrtc/callEventHandler'; import * as utils from './utils'; import {sleep} from './utils'; import { MatrixError, PREFIX_MEDIA_R0, PREFIX_UNSTABLE, retryNetworkOperation, } from "./http-api"; import {getHttpUriForMxc} from "./content-repo"; import * as ContentHelpers from "./content-helpers"; import * as olmlib from "./crypto/olmlib"; import {ReEmitter} from './ReEmitter'; import {RoomList} from './crypto/RoomList'; import {logger} from './logger'; import {Crypto, isCryptoAvailable, fixBackupKey} from './crypto'; import {decodeRecoveryKey} from './crypto/recoverykey'; import {keyFromAuthData} from './crypto/key_passphrase'; import {randomString} from './randomstring'; import {PushProcessor} from "./pushprocessor"; import {encodeBase64, decodeBase64} from "./crypto/olmlib"; import { User } from "./models/user"; import {AutoDiscovery} from "./autodiscovery"; import {DEHYDRATION_ALGORITHM} from "./crypto/dehydration"; const SCROLLBACK_DELAY_MS = 3000; export const CRYPTO_ENABLED = isCryptoAvailable(); const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes 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 megolm session from backup", e); } } 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.deviceToImport Device data exported with * "exportDevice" method that must be imported to recreate this device. * Should only be useful for devices with end-to-end crypto enabled. * If provided, opts.deviceId and opts.userId should **NOT** be provided * (they are present in the exported data). * * @param {string} opts.pickleKey Key used to pickle olm objects or other * sensitive data. * * @param {IdentityServerProvider} [opts.identityServer] * Optional. A provider object with one function `getAccessToken`, which is a * callback that returns a Promise of an identity access token to supply * with identity requests. If the object is unset, no access token will be * supplied. * See also https://github.com/vector-im/element-web/issues/10615 which seeks to * replace the previous approach of manual access tokens params with this * callback throughout the SDK. * * @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}. * * @param {boolean} [opts.forceTURN] * Optional. Whether relaying calls through a TURN server should be forced. * * * @param {boolean} [opts.iceCandidatePoolSize] * Optional. Up to this many ICE candidates will be gathered when an incoming call arrives. * Gathering does not send data to the caller, but will communicate with the configured TURN * server. Default 0. * * @param {boolean} [opts.supportsCallTransfer] * Optional. True to advertise support for call transfers to other parties on Matrix calls. * * @param {boolean} [opts.fallbackICEServerAllowed] * Optional. Whether to allow a fallback ICE server should be used for negotiating a * WebRTC connection if the homeserver doesn't provide any servers. Defaults to false. * * @param {boolean} [opts.usingExternalCrypto] * Optional. Whether to allow sending messages to encrypted rooms when encryption * is not available internally within this SDK. This is useful if you are using an external * E2E proxy, for example. Defaults to false. * * @param {object} opts.cryptoCallbacks Optional. Callbacks for crypto and cross-signing. * The cross-signing API is currently UNSTABLE and may change without notice. * * @param {function} [opts.cryptoCallbacks.getCrossSigningKey] * Optional. Function to call when a cross-signing private key is needed. * Secure Secret Storage will be used by default if this is unset. * Args: * {string} type The type of key needed. Will be one of "master", * "self_signing", or "user_signing" * {Uint8Array} publicKey The public key matching the expected private key. * This can be passed to checkPrivateKey() along with the private key * in order to check that a given private key matches what is being * requested. * Should return a promise that resolves with the private key as a * UInt8Array or rejects with an error. * * @param {function} [opts.cryptoCallbacks.saveCrossSigningKeys] * Optional. Called when new private keys for cross-signing need to be saved. * Secure Secret Storage will be used by default if this is unset. * Args: * {object} keys the private keys to save. Map of key name to private key * as a UInt8Array. The getPrivateKey callback above will be called * with the corresponding key name when the keys are required again. * * @param {function} [opts.cryptoCallbacks.shouldUpgradeDeviceVerifications] * Optional. Called when there are device-to-device verifications that can be * upgraded into cross-signing verifications. * Args: * {object} users The users whose device verifications can be * upgraded to cross-signing verifications. This will be a map of user IDs * to objects with the properties `devices` (array of the user's devices * that verified their cross-signing key), and `crossSigningInfo` (the * user's cross-signing information) * Should return a promise which resolves with an array of the user IDs who * should be cross-signed. * * @param {function} [opts.cryptoCallbacks.getSecretStorageKey] * Optional. Function called when an encryption key for secret storage * is required. One or more keys will be described in the keys object. * The callback function should return a promise with an array of: * [, ] or null if it cannot provide * any of the keys. * Args: * {object} keys Information about the keys: * { * keys: { * : { * "algorithm": "m.secret_storage.v1.aes-hmac-sha2", * "passphrase": { * "algorithm": "m.pbkdf2", * "iterations": 500000, * "salt": "..." * }, * "iv": "...", * "mac": "..." * }, ... * } * } * {string} name the name of the value we want to read out of SSSS, for UI purposes. * * @param {function} [opts.cryptoCallbacks.cacheSecretStorageKey] * Optional. Function called when a new encryption key for secret storage * has been created. This allows the application a chance to cache this key if * desired to avoid user prompts. * Args: * {string} keyId the ID of the new key * {object} keyInfo Infomation about the key as above for `getSecretStorageKey` * {Uint8Array} key the new private key * * @param {function} [opts.cryptoCallbacks.onSecretRequested] * Optional. Function called when a request for a secret is received from another * device. * Args: * {string} name The name of the secret being requested. * {string} userId The user ID of the client requesting * {string} deviceId The device ID of the client requesting the secret. * {string} requestId The ID of the request. Used to match a * corresponding `crypto.secrets.request_cancelled`. The request ID will be * unique per sender, device pair. * {DeviceTrustLevel} deviceTrust: The trust status of the device requesting * the secret as returned by {@link module:client~MatrixClient#checkDeviceTrust}. */ export function MatrixClient(opts) { opts.baseUrl = utils.ensureNoTrailingSlash(opts.baseUrl); opts.idBaseUrl = utils.ensureNoTrailingSlash(opts.idBaseUrl); MatrixBaseApis.call(this, opts); this.olmVersion = null; // Populated after initCrypto is done this.reEmitter = new ReEmitter(this); this.usingExternalCrypto = opts.usingExternalCrypto; this.store = opts.store || new StubStore(); this.deviceId = opts.deviceId || null; const userId = (opts.userId || null); this.credentials = { userId: userId, }; if (opts.deviceToImport) { if (this.deviceId) { logger.warn( 'not importing device because' + ' device ID is provided to constructor' + ' independently of exported data', ); } else if (this.credentials.userId) { logger.warn( 'not importing device because' + ' user ID is provided to constructor' + ' independently of exported data', ); } else if (!(opts.deviceToImport.deviceId)) { logger.warn('not importing device because no device ID in exported data'); } else { this.deviceId = opts.deviceToImport.deviceId; this.credentials.userId = opts.deviceToImport.userId; // will be used during async initialization of the crypto this._exportedOlmDeviceToImport = opts.deviceToImport.olmDevice; } } else if (opts.pickleKey) { this.pickleKey = opts.pickleKey; } this.scheduler = opts.scheduler; if (this.scheduler) { const self = this; this.scheduler.setProcessFunction(async function(eventToSend) { const room = self.getRoom(eventToSend.getRoomId()); if (eventToSend.status !== EventStatus.SENDING) { _updatePendingEventStatus(room, eventToSend, EventStatus.SENDING); } const res = await _sendEventHttpRequest(self, eventToSend); if (room) { // ensure we update pending event before the next scheduler run so that any listeners to event id // updates on the synchronous event emitter get a chance to run first. room.updatePendingEvent(eventToSend, EventStatus.SENT, res.event_id); } return res; }); } this.clientRunning = false; // 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 = createNewMatrixCall(this); this._supportsVoip = false; if (call) { this._callEventHandler = new CallEventHandler(this); this._supportsVoip = true; } else { this._callEventHandler = null; } this._syncingRetry = null; this._syncApi = null; this._peekSync = null; this._isGuest = false; this._ongoingScrollbacks = {}; this.timelineSupport = Boolean(opts.timelineSupport); this.urlPreviewCache = {}; // key=preview key, value=Promise for preview (may be an error) this._notifTimelineSet = null; this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation; this._crypto = null; this._cryptoStore = opts.cryptoStore; this._sessionStore = opts.sessionStore; this._verificationMethods = opts.verificationMethods; this._cryptoCallbacks = opts.cryptoCallbacks || {}; this._forceTURN = opts.forceTURN || false; this._iceCandidatePoolSize = opts.iceCandidatePoolSize === undefined ? 0 : opts.iceCandidatePoolSize; this._supportsCallTransfer = opts.supportsCallTransfer || false; this._fallbackICEServerAllowed = opts.fallbackICEServerAllowed || 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); // Promise to a response of the server's /versions response // TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020 this._serverVersionsPromise = null; this._cachedCapabilities = null; // { capabilities: {}, lastUpdated: timestamp } this._clientWellKnown = undefined; this._clientWellKnownPromise = undefined; this._turnServers = []; this._turnServersExpiry = 0; this._checkTurnServersIntervalID = null; // The SDK doesn't really provide a clean way for events to recalculate the push // actions for themselves, so we have to kinda help them out when they are encrypted. // We do this so that push rules are correctly executed on events in their decrypted // 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/element-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/element-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; } const pushActions = this.getPushActionsForEvent(event); highlightCount += pushActions.tweaks && pushActions.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); /** * Try to rehydrate a device if available. The client must have been * initialized with a `cryptoCallback.getDehydrationKey` option, and this * function must be called before initCrypto and startClient are called. * * @return {Promise} Resolves to undefined if a device could not be dehydrated, or * to the new device ID if the dehydration was successful. * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.rehydrateDevice = async function() { if (this._crypto) { throw new Error("Cannot rehydrate device after crypto is initialized"); } if (!this._cryptoCallbacks.getDehydrationKey) { return; } const getDeviceResult = this.getDehydratedDevice(); if (!getDeviceResult) { return; } if (!getDeviceResult.device_data || !getDeviceResult.device_id) { logger.info("no dehydrated device found"); return; } const account = new global.Olm.Account(); try { const deviceData = getDeviceResult.device_data; if (deviceData.algorithm !== DEHYDRATION_ALGORITHM) { logger.warn("Wrong algorithm for dehydrated device"); return; } logger.log("unpickling dehydrated device"); const key = await this._cryptoCallbacks.getDehydrationKey( deviceData, (k) => { // copy the key so that it doesn't get clobbered account.unpickle(new Uint8Array(k), deviceData.account); }, ); account.unpickle(key, deviceData.account); logger.log("unpickled device"); const rehydrateResult = await this._http.authedRequest( undefined, "POST", "/dehydrated_device/claim", undefined, { device_id: getDeviceResult.device_id, }, { prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2", }, ); if (rehydrateResult.success === true) { this.deviceId = getDeviceResult.device_id; logger.info("using dehydrated device"); const pickleKey = this.pickleKey || "DEFAULT_KEY"; this._exportedOlmDeviceToImport = { pickledAccount: account.pickle(pickleKey), sessions: [], pickleKey: pickleKey, }; account.free(); return this.deviceId; } else { account.free(); logger.info("not using dehydrated device"); return; } } catch (e) { account.free(); logger.warn("could not unpickle", e); } }; /** * Get the current dehydrated device, if any * @return {Promise} A promise of an object containing the dehydrated device */ MatrixClient.prototype.getDehydratedDevice = async function() { try { return await this._http.authedRequest( undefined, "GET", "/dehydrated_device", undefined, undefined, { prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2", }, ); } catch (e) { logger.info("could not get dehydrated device", e.toString()); return; } }; /** * Set the dehydration key. This will also periodically dehydrate devices to * the server. * * @param {Uint8Array} key the dehydration key * @param {object} [keyInfo] Information about the key. Primarily for * information about how to generate the key from a passphrase. * @param {string} [deviceDisplayName] The device display name for the * dehydrated device. * @return {Promise} A promise that resolves when the dehydrated device is stored. */ MatrixClient.prototype.setDehydrationKey = async function( key, keyInfo = {}, deviceDisplayName = undefined, ) { if (!(this._crypto)) { logger.warn('not dehydrating device if crypto is not enabled'); return; } return await this._crypto._dehydrationManager.setKeyAndQueueDehydration( key, keyInfo, deviceDisplayName, ); }; /** * Creates a new dehydrated device (without queuing periodic dehydration) * @param {Uint8Array} key the dehydration key * @param {object} [keyInfo] Information about the key. Primarily for * information about how to generate the key from a passphrase. * @param {string} [deviceDisplayName] The device display name for the * dehydrated device. * @return {Promise} the device id of the newly created dehydrated device */ MatrixClient.prototype.createDehydratedDevice = async function( key, keyInfo = {}, deviceDisplayName = undefined, ) { if (!(this._crypto)) { logger.warn('not dehydrating device if crypto is not enabled'); return; } await this._crypto._dehydrationManager.setKey( key, keyInfo, deviceDisplayName, ); return await this._crypto._dehydrationManager.dehydrateDevice(); }; MatrixClient.prototype.exportDevice = async function() { if (!(this._crypto)) { logger.warn('not exporting device if crypto is not enabled'); return; } return { userId: this.credentials.userId, deviceId: this.deviceId, olmDevice: await this._crypto._olmDevice.export(), }; }; /** * 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; }; /** * Set whether to advertise transfer support to other parties on Matrix calls. * @param {bool} supportsCallTransfer True to advertise the 'm.call.transferee' capability */ MatrixClient.prototype.setSupportsCallTransfer = function(supportsCallTransfer) { this._supportsCallTransfer = supportsCallTransfer; }; /** * 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(); }; /** * Whether the initial sync has completed. * @return {boolean} True if at least on sync has happened. */ MatrixClient.prototype.isInitialSyncComplete = function() { const state = this.getSyncState(); if (!state) { return false; } return state === "PREPARED" || state === "SYNCING"; }; /** * 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 {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`); } logger.log("Crypto: Starting up crypto store..."); await this._cryptoStore.startup(); // 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", "crypto.devicesUpdated", "crypto.willUpdateDevices", "deviceVerificationChanged", "userTrustStatusChanged", "crossSigning.keysChanged", ]); logger.log("Crypto: initialising crypto object..."); await crypto.init({ exportedOlmDevice: this._exportedOlmDeviceToImport, pickleKey: this.pickleKey, }); delete this._exportedOlmDeviceToImport; 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(); }; /** * Get the Curve25519 key for this device * * @return {?string} base64-encoded curve25519 key. Null if crypto is * disabled. */ MatrixClient.prototype.getDeviceCurve25519Key = function() { if (!this._crypto) { return null; } return this._crypto.getDeviceCurve25519Key(); }; /** * 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 {module:crypto/deviceinfo[]} list of devices */ MatrixClient.prototype.getStoredDevicesForUser = 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 {module:crypto/deviceinfo} device or null */ MatrixClient.prototype.getStoredDevice = 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 or user's * cross-signing public key ID. * * @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 or user's * cross-signing public key ID. * * @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 or user's * cross-signing public key ID. * * @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"); } await client._crypto.setDeviceVerification( userId, deviceId, verified, blocked, known, ); } /** * Request a key verification from another user, using a DM. * * @param {string} userId the user to request verification with * @param {string} roomId the room to use for verification * * @returns {Promise} resolves to a VerificationRequest * when the request has been sent to the other party. */ MatrixClient.prototype.requestVerificationDM = function(userId, roomId) { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } return this._crypto.requestVerificationDM(userId, roomId); }; /** * Finds a DM verification request that is already in progress for the given room id * * @param {string} roomId the room to use for verification * * @returns {module:crypto/verification/request/VerificationRequest?} the VerificationRequest that is in progress, if any */ MatrixClient.prototype.findVerificationRequestDMInProgress = function(roomId) { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } return this._crypto.findVerificationRequestDMInProgress(roomId); }; /** * Returns all to-device verification requests that are already in progress for the given user id * * @param {string} userId the ID of the user to query * * @returns {module:crypto/verification/request/VerificationRequest[]} the VerificationRequests that are in progress */ MatrixClient.prototype.getVerificationRequestsToDeviceInProgress = function(userId) { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } return this._crypto.getVerificationRequestsToDeviceInProgress(userId); }; /** * Request a key verification from another user. * * @param {string} userId the user to request verification with * @param {Array} devices array of device IDs to send requests to. Defaults to * all devices owned by the user * * @returns {Promise} resolves to a VerificationRequest * when the request has been sent to the other party. */ MatrixClient.prototype.requestVerification = function(userId, devices) { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } return this._crypto.requestVerification(userId, 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(); }; /** * Set whether sendMessage in a room with unknown and unverified devices * should throw an error and not send them message. This has 'Global' for * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently * no room-level equivalent for this setting. * * This API is currently UNSTABLE and may change or be removed without notice. * * @param {boolean} value whether error on unknown devices */ MatrixClient.prototype.setGlobalErrorOnUnknownDevices = function(value) { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } this._crypto.setGlobalErrorOnUnknownDevices(value); }; /** * @return {boolean} whether to error on unknown devices * * This API is currently UNSTABLE and may change or be removed without notice. */ MatrixClient.prototype.getGlobalErrorOnUnknownDevices = function() { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } return this._crypto.getGlobalErrorOnUnknownDevices(); }; /** * Add methods that call the corresponding method in this._crypto * * @param {class} MatrixClient the class to add the method to * @param {string} names the names of the methods to call */ function wrapCryptoFuncs(MatrixClient, names) { for (const name of names) { MatrixClient.prototype[name] = function(...args) { if (!this._crypto) { // eslint-disable-line no-invalid-this throw new Error("End-to-end encryption disabled"); } return this._crypto[name](...args); // eslint-disable-line no-invalid-this }; } } /** * Get the user's cross-signing key ID. * * The cross-signing API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#getCrossSigningId * @param {string} [type=master] The type of key to get the ID of. One of * "master", "self_signing", or "user_signing". Defaults to "master". * * @returns {string} the key ID */ /** * Get the cross signing information for a given user. * * The cross-signing API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#getStoredCrossSigningForUser * @param {string} userId the user ID to get the cross-signing info for. * * @returns {CrossSigningInfo} the cross signing information for the user. */ /** * Check whether a given user is trusted. * * The cross-signing API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#checkUserTrust * @param {string} userId The ID of the user to check. * * @returns {UserTrustLevel} */ /** * Check whether a given device is trusted. * * The cross-signing API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#checkDeviceTrust * @param {string} userId The ID of the user whose devices is to be checked. * @param {string} deviceId The ID of the device to check * * @returns {DeviceTrustLevel} */ /** * Check the copy of our cross-signing key that we have in the device list and * see if we can get the private key. If so, mark it as trusted. * * The cross-signing API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#checkOwnCrossSigningTrust */ /** * Checks that a given cross-signing private key matches a given public key. * This can be used by the getCrossSigningKey callback to verify that the * private key it is about to supply is the one that was requested. * * The cross-signing API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#checkCrossSigningPrivateKey * @param {Uint8Array} privateKey The private key * @param {string} expectedPublicKey The public key * @returns {boolean} true if the key matches, otherwise false */ /** * Perform any background tasks that can be done before a message is ready to * send, in order to speed up sending of the message. * * @function module:client~MatrixClient#prepareToEncrypt * @param {module:models/room} room the room the event is in */ /** * Checks whether cross signing: * - is enabled on this account and trusted by this device * - has private keys either cached locally or stored in secret storage * * If this function returns false, bootstrapCrossSigning() can be used * to fix things such that it returns true. That is to say, after * bootstrapCrossSigning() completes successfully, this function should * return true. * * The cross-signing API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#isCrossSigningReady * @return {bool} True if cross-signing is ready to be used on this device */ /** * Bootstrap cross-signing by creating keys if needed. If everything is already * set up, then no changes are made, so this is safe to run to ensure * cross-signing is ready for use. * * This function: * - creates new cross-signing keys if they are not found locally cached nor in * secret storage (if it has been setup) * * The cross-signing API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#bootstrapCrossSigning * @param {function} opts.authUploadDeviceSigningKeys Function * called to await an interactive auth flow when uploading device signing keys. * @param {bool} [opts.setupNewCrossSigning] Optional. Reset even if keys * already exist. * Args: * {function} A function that makes the request requiring auth. Receives the * auth data as an object. Can be called multiple times, first with an empty * authDict, to obtain the flows. */ wrapCryptoFuncs(MatrixClient, [ "getCrossSigningId", "getStoredCrossSigningForUser", "checkUserTrust", "checkDeviceTrust", "checkOwnCrossSigningTrust", "checkCrossSigningPrivateKey", "legacyDeviceVerification", "prepareToEncrypt", "isCrossSigningReady", "bootstrapCrossSigning", "getCryptoTrustCrossSignedDevices", "setCryptoTrustCrossSignedDevices", "countSessionsNeedingBackup", ]); /** * Get information about the encryption of an event * * @function module:client~MatrixClient#getEventEncryptionInfo * * @param {module:models/event.MatrixEvent} event event to be checked * * @return {object} An object with the fields: * - encrypted: whether the event is encrypted (if not encrypted, some of the * other properties may not be set) * - senderKey: the sender's key * - algorithm: the algorithm used to encrypt the event * - authenticated: whether we can be sure that the owner of the senderKey * sent the event * - sender: the sender's device information, if available * - mismatchedSender: if the event's ed25519 and curve25519 keys don't match * (only meaningful if `sender` is set) */ /** * Create a recovery key from a user-supplied passphrase. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#createRecoveryKeyFromPassphrase * @param {string} password Passphrase string that can be entered by the user * when restoring the backup as an alternative to entering the recovery key. * Optional. * @returns {Promise} Object with public key metadata, encoded private * recovery key which should be disposed of after displaying to the user, * and raw private key to avoid round tripping if needed. */ /** * Checks whether secret storage: * - is enabled on this account * - is storing cross-signing private keys * - is storing session backup key (if enabled) * * If this function returns false, bootstrapSecretStorage() can be used * to fix things such that it returns true. That is to say, after * bootstrapSecretStorage() completes successfully, this function should * return true. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#isSecretStorageReady * @return {bool} True if secret storage is ready to be used on this device */ /** * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is * already set up, then no changes are made, so this is safe to run to ensure secret * storage is ready for use. * * This function * - creates a new Secure Secret Storage key if no default key exists * - if a key backup exists, it is migrated to store the key in the Secret * Storage * - creates a backup if none exists, and one is requested * - migrates Secure Secret Storage to use the latest algorithm, if an outdated * algorithm is found * * @function module:client~MatrixClient#bootstrapSecretStorage * @param {function} [opts.createSecretStorageKey] Optional. Function * called to await a secret storage key creation flow. * Returns: * {Promise} Object with public key metadata, encoded private * recovery key which should be disposed of after displaying to the user, * and raw private key to avoid round tripping if needed. * @param {object} [opts.keyBackupInfo] The current key backup object. If passed, * the passphrase and recovery key from this backup will be used. * @param {bool} [opts.setupNewKeyBackup] If true, a new key backup version will be * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo * is supplied. * @param {bool} [opts.setupNewSecretStorage] Optional. Reset even if keys already exist. * @param {func} [opts.getKeyBackupPassphrase] Optional. Function called to get the user's * current key backup passphrase. Should return a promise that resolves with a Buffer * containing the key, or rejects if the key cannot be obtained. * Returns: * {Promise} A promise which resolves to key creation data for * SecretStorage#addKey: an object with `passphrase` etc fields. */ /** * Add a key for encrypting secrets. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#addSecretStorageKey * @param {string} algorithm the algorithm used by the key * @param {object} opts the options for the algorithm. The properties used * depend on the algorithm given. * @param {string} [keyName] the name of the key. If not given, a random * name will be generated. * * @return {object} An object with: * keyId: {string} the ID of the key * keyInfo: {object} details about the key (iv, mac, passphrase) */ /** * Check whether we have a key with a given ID. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#hasSecretStorageKey * @param {string} [keyId = default key's ID] The ID of the key to check * for. Defaults to the default key ID if not provided. * @return {boolean} Whether we have the key. */ /** * Store an encrypted secret on the server. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#storeSecret * @param {string} name The name of the secret * @param {string} secret The secret contents. * @param {Array} keys The IDs of the keys to use to encrypt the secret or null/undefined * to use the default (will throw if no default key is set). */ /** * Get a secret from storage. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#getSecret * @param {string} name the name of the secret * * @return {string} the contents of the secret */ /** * Check if a secret is stored on the server. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#isSecretStored * @param {string} name the name of the secret * @param {boolean} checkKey check if the secret is encrypted by a trusted * key * * @return {object?} map of key name to key info the secret is encrypted * with, or null if it is not present or not encrypted with a trusted * key */ /** * Request a secret from another device. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#requestSecret * @param {string} name the name of the secret to request * @param {string[]} devices the devices to request the secret from * * @return {string} the contents of the secret */ /** * Get the current default key ID for encrypting secrets. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#getDefaultSecretStorageKeyId * * @return {string} The default key ID or null if no default key ID is set */ /** * Set the current default key ID for encrypting secrets. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#setDefaultSecretStorageKeyId * @param {string} keyId The new default key ID */ /** * Checks that a given secret storage private key matches a given public key. * This can be used by the getSecretStorageKey callback to verify that the * private key it is about to supply is the one that was requested. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @function module:client~MatrixClient#checkSecretStoragePrivateKey * @param {Uint8Array} privateKey The private key * @param {string} expectedPublicKey The public key * @returns {boolean} true if the key matches, otherwise false */ wrapCryptoFuncs(MatrixClient, [ "getEventEncryptionInfo", "createRecoveryKeyFromPassphrase", "isSecretStorageReady", "bootstrapSecretStorage", "addSecretStorageKey", "hasSecretStorageKey", "storeSecret", "getSecret", "isSecretStored", "requestSecret", "getDefaultSecretStorageKeyId", "setDefaultSecretStorageKeyId", "checkSecretStorageKey", "checkSecretStoragePrivateKey", ]); /** * 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. This does not modify room state. * Any messages sent before the returned promise resolves will be sent unencrypted. * @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 {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 * @param {Object} opts * @param {Function} opts.progressCallback called with an object that has a "stage" param * * @return {Promise} a promise which resolves when the keys * have been imported */ MatrixClient.prototype.importRoomKeys = function(keys, opts) { if (!this._crypto) { throw new Error("End-to-end encryption disabled"); } return this._crypto.importRoomKeys(keys, opts); }; /** * 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: 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. If we haven't completed a successful check * of key backup status yet, returns null. */ MatrixClient.prototype.getKeyBackupEnabled = function() { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } if (!this._crypto._checkedForBackup) { return null; } 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. * @param {boolean} [opts.secureSecretStorage = false] Whether to use Secure * Secret Storage to store the key encrypting key backups. * Optional, defaults to false. * * @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, { secureSecretStorage = false } = {}, ) { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } const { keyInfo, encodedPrivateKey, privateKey } = await this.createRecoveryKeyFromPassphrase(password); if (secureSecretStorage) { await this.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey)); logger.info("Key backup private key stored in secret storage"); } // Reshape objects into form expected for key backup const authData = { public_key: keyInfo.pubkey, }; if (keyInfo.passphrase) { authData.private_key_salt = keyInfo.passphrase.salt; authData.private_key_iterations = keyInfo.passphrase.iterations; } return { algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM, auth_data: authData, recovery_key: encodedPrivateKey, }; }; /** * Check whether the key backup private key is stored in secret storage. * @return {Promise} map of key name to key info the secret is * encrypted with, or null if it is not present or not encrypted with a * trusted key */ MatrixClient.prototype.isKeyBackupKeyStored = async function() { return this.isSecretStored("m.megolm_backup.v1", false /* checkKey */); }; /** * 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 = async function(info) { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } const data = { algorithm: info.algorithm, auth_data: info.auth_data, }; // Sign the backup auth data with the device key for backwards compat with // older devices with cross-signing. This can probably go away very soon in // favour of just signing with the cross-singing master key. await this._crypto._signObject(data.auth_data); if ( this._cryptoCallbacks.getCrossSigningKey && this._crypto._crossSigningInfo.getId() ) { // now also sign the auth data with the cross-signing master key // we check for the callback explicitly here because we still want to be able // to create an un-cross-signed key backup if there is a cross-signing key but // no callback supplied. await this._crypto._crossSigningInfo.signObject(data.auth_data, "master"); } const res = await this._http.authedRequest( undefined, "POST", "/room_keys/version", undefined, data, {prefix: PREFIX_UNSTABLE}, ); // We could assume everything's okay and enable directly, but this ensures // we run the same signature verification that will be used for future // sessions. await this.checkKeyBackup(); if (!this.getKeyBackupEnabled()) { logger.error("Key backup not usable even though we just created it"); } 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: 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 {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: 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; } }; /** * Get the raw key for a key backup from the password * Used when migrating key backups into SSSS * * The cross-signing API is currently UNSTABLE and may change without notice. * * @param {string} password Passphrase * @param {object} backupInfo Backup metadata from `checkKeyBackup` * @return {Promise} key backup key */ MatrixClient.prototype.keyBackupKeyFromPassword = function( password, backupInfo, ) { return keyFromAuthData(backupInfo.auth_data, password); }; /** * Get the raw key for a key backup from the recovery key * Used when migrating key backups into SSSS * * The cross-signing API is currently UNSTABLE and may change without notice. * * @param {string} recoveryKey The recovery key * @return {Uint8Array} key backup key */ MatrixClient.prototype.keyBackupKeyFromRecoveryKey = function(recoveryKey) { return decodeRecoveryKey(recoveryKey); }; MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY = 'RESTORE_BACKUP_ERROR_BAD_KEY'; /** * Restore from an existing key backup via a passphrase. * * @param {string} password Passphrase * @param {string} [targetRoomId] Room ID to target a specific room. * Restores all rooms if omitted. * @param {string} [targetSessionId] Session ID to target a specific session. * Restores all sessions if omitted. * @param {object} backupInfo Backup metadata from `checkKeyBackup` * @param {object} opts Optional params such as callbacks * @return {Promise} Status of restoration with `total` and `imported` * key counts. */ MatrixClient.prototype.restoreKeyBackupWithPassword = async function( password, targetRoomId, targetSessionId, backupInfo, opts, ) { const privKey = await keyFromAuthData(backupInfo.auth_data, password); return this._restoreKeyBackup( privKey, targetRoomId, targetSessionId, backupInfo, opts, ); }; /** * Restore from an existing key backup via a private key stored in secret * storage. * * @param {object} backupInfo Backup metadata from `checkKeyBackup` * @param {string} [targetRoomId] Room ID to target a specific room. * Restores all rooms if omitted. * @param {string} [targetSessionId] Session ID to target a specific session. * Restores all sessions if omitted. * @param {object} opts Optional params such as callbacks * @return {Promise} Status of restoration with `total` and `imported` * key counts. */ MatrixClient.prototype.restoreKeyBackupWithSecretStorage = async function( backupInfo, targetRoomId, targetSessionId, opts, ) { const storedKey = await this.getSecret("m.megolm_backup.v1"); // ensure that the key is in the right format. If not, fix the key and // store the fixed version const fixedKey = fixBackupKey(storedKey); if (fixedKey) { const [keyId] = await this._crypto.getSecretStorageKey(); await this.storeSecret("m.megolm_backup.v1", fixedKey, [keyId]); } const privKey = decodeBase64(fixedKey || storedKey); return this._restoreKeyBackup( privKey, targetRoomId, targetSessionId, backupInfo, opts, ); }; /** * Restore from an existing key backup via an encoded recovery key. * * @param {string} recoveryKey Encoded recovery key * @param {string} [targetRoomId] Room ID to target a specific room. * Restores all rooms if omitted. * @param {string} [targetSessionId] Session ID to target a specific session. * Restores all sessions if omitted. * @param {object} backupInfo Backup metadata from `checkKeyBackup` * @param {object} opts Optional params such as callbacks * @return {Promise} Status of restoration with `total` and `imported` * key counts. */ MatrixClient.prototype.restoreKeyBackupWithRecoveryKey = function( recoveryKey, targetRoomId, targetSessionId, backupInfo, opts, ) { const privKey = decodeRecoveryKey(recoveryKey); return this._restoreKeyBackup( privKey, targetRoomId, targetSessionId, backupInfo, opts, ); }; /** * Restore from an existing key backup using a cached key, or fail * * @param {string} [targetRoomId] Room ID to target a specific room. * Restores all rooms if omitted. * @param {string} [targetSessionId] Session ID to target a specific session. * Restores all sessions if omitted. * @param {object} backupInfo Backup metadata from `checkKeyBackup` * @param {object} opts Optional params such as callbacks * @return {Promise} Status of restoration with `total` and `imported` * key counts. */ MatrixClient.prototype.restoreKeyBackupWithCache = async function( targetRoomId, targetSessionId, backupInfo, opts, ) { const privKey = await this._crypto.getSessionBackupPrivateKey(); if (!privKey) { throw new Error("Couldn't get key"); } return this._restoreKeyBackup( privKey, targetRoomId, targetSessionId, backupInfo, opts, ); }; MatrixClient.prototype._restoreKeyBackup = function( privKey, targetRoomId, targetSessionId, backupInfo, { cacheCompleteCallback, // For sequencing during tests progressCallback, }={}, ) { 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}); } // Cache the key, if possible. // This is async. this._crypto.storeSessionBackupPrivateKey(privKey) .catch((e) => { logger.warn("Error caching session backup key:", e); }).then(cacheCompleteCallback); if (progressCallback) { progressCallback({ stage: "fetch", }); } return this._http.authedRequest( undefined, "GET", path.path, path.queryData, undefined, {prefix: 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, ); 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 megolm session from backup", e); } } return this.importRoomKeys(keys, { progressCallback, untrusted: true, source: "backup", }); }).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: PREFIX_UNSTABLE}, ); }; /** * Share shared-history decryption keys with the given users. * * @param {string} roomId the room for which keys should be shared. * @param {array} userIds a list of users to share with. The keys will be sent to * all of the user's current devices. */ MatrixClient.prototype.sendSharedHistoryKeys = async function(roomId, userIds) { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } const roomEncryption = this._roomList.getRoomEncryption(roomId); if (!roomEncryption) { // unknown room, or unencrypted room logger.error("Unknown room. Not sharing decryption keys"); return; } const deviceInfos = await this._crypto.downloadKeys(userIds); const devicesByUser = {}; for (const [userId, devices] of Object.entries(deviceInfos)) { devicesByUser[userId] = Object.values(devices); } const alg = this._crypto._getRoomDecryptor(roomId, roomEncryption.algorithm); if (alg.sendSharedHistoryInboundSessions) { await alg.sendSharedHistoryInboundSessions(devicesByUser); } else { logger.warning("Algorithm does not support sharing previous keys", roomEncryption.algorithm); } }; // 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 {Promise} Resolves with an object containing the config. */ MatrixClient.prototype.getMediaConfig = function(callback) { return this._http.authedRequest( callback, "GET", "/config", undefined, undefined, { prefix: 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. * It will retry the request up to 5 times. * @param {string} eventType The event type * @param {Object} contents the contents object for the event * @param {module:client.callback} callback Optional. * @return {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, }); const promise = retryNetworkOperation(5, () => { return this._http.authedRequest(undefined, "PUT", path, undefined, contents); }); if (callback) { promise.then(result => callback(null, result), callback); } return promise; }; /** * 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); }; /** * Get account data event of given type for the current user. This variant * gets account data directly from the homeserver if the local store is not * ready, which can be useful very early in startup before the initial sync. * @param {string} eventType The event type * @return {Promise} Resolves: The contents of the given account * data event. * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.getAccountDataFromServer = async function(eventType) { if (this.isInitialSyncComplete()) { const event = this.store.getAccountData(eventType); if (!event) { return null; } // The network version below returns just the content, so this branch // does the same to match. return event.getContent(); } const path = utils.encodeUri("/user/$userId/account_data/$type", { $userId: this.credentials.userId, $type: eventType, }); try { const result = await this._http.authedRequest( undefined, "GET", path, undefined, ); return result; } catch (e) { if (e.data && e.data.errcode === 'M_NOT_FOUND') { return null; } throw e; } }; /** * 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 {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 {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 self = this; const prom = new Promise((resolve, reject) => { 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); }).then(function(room) { _resolve(callback, resolve, room); }, function(err) { _reject(callback, reject, err); }); }); return prom; }; /** * 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 {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 {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 {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 {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 {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 {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 {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 {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 {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 {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, sender: 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.setTxnId(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 promise 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); if (room) { promise = promise.then(res => { room.updatePendingEvent(event, EventStatus.SENT, res.event_id); return res; }); } } return promise; }).then(function(res) { // the request was sent OK 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 {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 (!client._crypto && client.usingExternalCrypto) { // The client has opted to allow sending messages to encrypted // rooms even if the room is encrypted, and we haven't setup // crypto. This is useful for users of matrix-org/pantalaimon 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) { let txnId = event.getTxnId(); if (!txnId) { txnId = client.makeTxnId(); event.setTxnId(txnId); } 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 {object|module:client.callback} callbackOrOpts * Options to pass on, may contain `reason`. * Can be callback for backwards compatibility. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.redactEvent = function(roomId, eventId, txnId, callbackOrOpts) { const opts = typeof(callbackOrOpts) === 'object' ? callbackOrOpts : {}; const reason = opts.reason; const callback = typeof(callbackOrOpts) === 'function' ? callbackOrOpts : undefined; return this._sendCompleteEvent(roomId, { type: "m.room.redaction", content: { reason: reason }, redacts: eventId, }, txnId, callback); }; /** * @param {string} roomId * @param {Object} content * @param {string} txnId Optional. * @param {module:client.callback} callback Optional. * @return {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 {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 {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 {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 {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 {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 {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 {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 {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 {object} opts Additional content to send alongside the receipt. * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.sendReceipt = function(event, receiptType, opts, callback) { if (typeof(opts) === 'function') { callback = opts; opts = {}; } 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, opts || {}, ); 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 {object} opts The options for the read receipt. * @param {boolean} opts.hidden True to prevent the receipt from being sent to * other users and homeservers. Default false (send to everyone). This * property is unstable and may change in the future. * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.sendReadReceipt = async function(event, opts, callback) { if (typeof(opts) === 'function') { callback = opts; opts = {}; } if (!opts) opts = {}; 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})`); } const addlContent = { "m.hidden": Boolean(opts.hidden), }; return this.sendReceipt(event, "m.read", addlContent, 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. * @param {object} opts Options for the read markers * @param {object} opts.hidden True to hide the receipt from other users and homeservers. * This property is unstable and may change in the future. * @return {Promise} Resolves: the empty object, {}. */ MatrixClient.prototype.setRoomReadMarkers = async function( roomId, rmEventId, rrEvent, opts, ) { 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, opts); }; /** * 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 {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) { // bucket the timestamp to the nearest minute to prevent excessive spam to the server // Surely 60-second accuracy is enough for anyone. ts = Math.floor(ts / 60000) * 60000; const key = ts + "_" + url; // If there's already a request in flight (or we've handled it), return that instead. const cachedPreview = this.urlPreviewCache[key]; if (cachedPreview) { if (callback) { cachedPreview.then(callback).catch(callback); } return cachedPreview; } const resp = this._http.authedRequest( callback, "GET", "/preview_url", { url: url, ts: ts, }, undefined, { prefix: PREFIX_MEDIA_R0, }, ); // TODO: Expire the URL preview cache sometimes this.urlPreviewCache[key] = resp; return resp; }; /** * @param {string} roomId * @param {boolean} isTyping * @param {Number} timeoutMs * @param {module:client.callback} callback Optional. * @return {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 (refRoom.roomId === currentRoom.roomId) break; // Tombstone is referencing it's own room 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); const roomIds = new Set(upgradeHistory.map((ref) => ref.roomId)); if (roomIds.size < upgradeHistory.length) { // The last room added to the list introduced a previous roomId // To avoid recursion, return the last rooms - 1 return upgradeHistory.slice(0, upgradeHistory.length - 1); } // 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. * @param {string} reason Optional. * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.invite = function(roomId, userId, callback, reason) { return _membershipChange(this, roomId, userId, "invite", reason, 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 {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 {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.inviteByThreePid = async 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", })); } const params = { id_server: identityServerUrl, medium: medium, address: address, }; if ( this.identityServer && this.identityServer.getAccessToken && await this.doesServerAcceptIdentityAccessToken() ) { const identityAccessToken = await this.identityServer.getAccessToken(); if (identityAccessToken) { params.id_access_token = identityAccessToken; } } return this._http.authedRequest(callback, "POST", path, undefined, params); }; /** * @param {string} roomId * @param {module:client.callback} callback Optional. * @return {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 {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 {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 {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 {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 {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 {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 {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 {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 {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.setDisplayName = async function(name, callback) { const prom = await this.setProfileInfo( "displayname", { displayname: name }, callback, ); // XXX: synthesise a profile update for ourselves because Synapse is broken and won't const user = this.getUser(this.getUserId()); if (user) { user.displayName = name; user.emit("User.displayName", user.events.presence, user); } return prom; }; /** * @param {string} url * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.setAvatarUrl = async function(url, callback) { const prom = await this.setProfileInfo( "avatar_url", { avatar_url: url }, callback, ); // XXX: synthesise a profile update for ourselves because Synapse is broken and won't const user = this.getUser(this.getUserId()); if (user) { user.avatarUrl = url; user.emit("User.avatarUrl", user.events.presence, user); } return prom; }; /** * 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 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 {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 {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, ); }; /** * @param {string} userId The user to get presence for * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: The presence state for this user. * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.getPresence = function(userId, callback) { const path = utils.encodeUri("/presence/$userId/status", { $userId: userId, }); return this._http.authedRequest(callback, "GET", path, undefined, undefined); }; /** * 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 {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 self = this; const prom = new Promise((resolve, reject) => { // wait for a time before doing this request // (which may be 0 in order not to special case the code paths) sleep(timeToWaitMs).then(function() { return self._createMessagesRequest( room.roomId, room.oldState.paginationToken, limit, 'b'); }).then(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, resolve, room); }, function(err) { self._ongoingScrollbacks[room.roomId] = { errorTs: Date.now(), }; _reject(callback, reject, err); }); }); info = { promise: prom, errorTs: null, }; this._ongoingScrollbacks[room.roomId] = info; return prom; }; /** * 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 {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 {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; let params; let 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 {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 {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 requests a token from the homeserver. * The doesServerRequireIdServerParam() method can be used to determine if * the server requires the id_server parameter to be provided. * * 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 {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 requests a token from the homeserver. * The doesServerRequireIdServerParam() method can be used to determine if * the server requires the id_server parameter to be provided. * * @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 {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 requests a token from the homeserver. * The doesServerRequireIdServerParam() method can be used to determine if * the server requires the id_server parameter to be provided. * If an account with the given email address already exists and is * associated with an account other than the one the user is authed as, * 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). * * @param {string} email As requestEmailToken * @param {string} clientSecret As requestEmailToken * @param {number} sendAttempt As requestEmailToken * @param {string} nextLink As requestEmailToken * @return {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 {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 {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 {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 {Promise} Resolves: As requestEmailToken */ MatrixClient.prototype._requestTokenFromEndpoint = async function(endpoint, params) { const postParams = Object.assign({}, params); // If the HS supports separate add and bind, then requestToken endpoints // don't need an IS as they are all validated by the HS directly. if (!await this.doesServerSupportSeparateAddAndBind() && this.idBaseUrl) { const idServerUrl = url.parse(this.idBaseUrl); if (!idServerUrl.host) { throw new Error("Invalid ID server URL: " + this.idBaseUrl); } postParams.id_server = idServerUrl.host; if ( this.identityServer && this.identityServer.getAccessToken && await this.doesServerAcceptIdentityAccessToken() ) { const identityAccessToken = await this.identityServer.getAccessToken(); if (identityAccessToken) { postParams.id_access_token = identityAccessToken; } } } 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 {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; let 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 = utils.defer(); this.deletePushRule(scope, "room", roomPushRule.rule_id) .then(function() { self.addPushRule(scope, "room", roomId, { actions: ["dont_notify"], }).then(function() { deferred.resolve(); }, function(err) { deferred.reject(err); }); }, function(err) { deferred.reject(err); }); deferred = deferred.promise; } } if (deferred) { return new Promise((resolve, reject) => { // Update this.pushRules when the operation completes deferred.then(function() { self.getPushRules().then(function(result) { self.pushRules = result; resolve(); }, function(err) { 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().then(function(result) { self.pushRules = result; reject(err); }, function(err2) { reject(err); }); }); }); } }; // 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 {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 {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 {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 {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 {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 = async function(filterName, filter) { const filterId = this.store.getFilterIdByName(filterName); let existingId = undefined; if (filterId) { // check that the existing filter matches our expectations try { const existingFilter = await this.getFilter(this.credentials.userId, filterId, true); if (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)); existingId = filterId; } } } catch (error) { // Synapse currently returns the following when the filter cannot be found: // { // errcode: "M_UNKNOWN", // name: "M_UNKNOWN", // message: "No row found", // } if (error.errcode !== "M_UNKNOWN" && error.errcode !== "M_NOT_FOUND") { throw error; } } // if the filter doesn't exist anymore on the server, remove from store if (!existingId) { this.store.setFilterIdByName(filterName, undefined); } } if (existingId) { return existingId; } // create a new filter const createdFilter = await this.createFilter(filter.getDefinition()); // debuglog("Created new filter ID %s: %s", createdFilter.filterId, // JSON.stringify(createdFilter.getDefinition())); this.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 {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 {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 || []; }; /** * Get the unix timestamp (in seconds) at which the current * TURN credentials (from getTurnServers) expire * @return {number} The expiry timestamp, in seconds, or null if no credentials */ MatrixClient.prototype.getTurnServersExpiry = function() { return this._turnServersExpiry; }; MatrixClient.prototype._checkTurnServers = async function() { if (!this._supportsVoip) { return; } let credentialsGood = false; const remainingTime = this._turnServersExpiry - Date.now(); if (remainingTime > TURN_CHECK_INTERVAL) { logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones."); credentialsGood = true; } else { logger.debug("Fetching new TURN credentials"); try { const res = await this.turnServer(); 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, }; this._turnServers = [servers]; // The TTL is in seconds but we work in ms this._turnServersExpiry = Date.now() + (res.ttl * 1000); credentialsGood = true; } } catch (err) { logger.error("Failed to get TURN URIs", err); // If we get a 403, there's no point in looping forever. if (err.httpStatus === 403) { logger.info("TURN access unavailable for this account: stopping credentials checks"); if (this._checkTurnServersIntervalID !== null) global.clearInterval(this._checkTurnServersIntervalID); this._checkTurnServersIntervalID = null; } } // otherwise, if we failed for whatever reason, try again the next time we're called. } return credentialsGood; }; /** * Set whether to allow a fallback ICE server should be used for negotiating a * WebRTC connection if the homeserver doesn't provide any servers. Defaults to * false. * * @param {boolean} allow */ MatrixClient.prototype.setFallbackICEServerAllowed = function(allow) { this._fallbackICEServerAllowed = allow; }; /** * Get whether to allow a fallback ICE server should be used for negotiating a * WebRTC connection if the homeserver doesn't provide any servers. Defaults to * false. * * @returns {boolean} */ MatrixClient.prototype.isFallbackICEServerAllowed = function() { return this._fallbackICEServerAllowed; }; // Synapse-specific APIs // ===================== /** * Determines if the current user is an administrator of the Synapse homeserver. * Returns false if untrue or the homeserver does not appear to be a Synapse * homeserver. This function is implementation specific and may change * as a result. * @return {boolean} true if the user appears to be a Synapse administrator. */ MatrixClient.prototype.isSynapseAdministrator = function() { const path = utils.encodeUri( "/_synapse/admin/v1/users/$userId/admin", { $userId: this.getUserId() }, ); return this._http.authedRequest( undefined, 'GET', path, undefined, undefined, {prefix: ''}, ).then(r => r['admin']); // pull out the specific boolean we want }; /** * Performs a whois lookup on a user using Synapse's administrator API. * This function is implementation specific and may change as a * result. * @param {string} userId the User ID to look up. * @return {object} the whois response - see Synapse docs for information. */ MatrixClient.prototype.whoisSynapseUser = function(userId) { const path = utils.encodeUri( "/_synapse/admin/v1/whois/$userId", { $userId: userId }, ); return this._http.authedRequest( undefined, 'GET', path, undefined, undefined, {prefix: ''}, ); }; /** * Deactivates a user using Synapse's administrator API. This * function is implementation specific and may change as a result. * @param {string} userId the User ID to deactivate. * @return {object} the deactivate response - see Synapse docs for information. */ MatrixClient.prototype.deactivateSynapseUser = function(userId) { const path = utils.encodeUri( "/_synapse/admin/v1/deactivate/$userId", { $userId: userId }, ); return this._http.authedRequest( undefined, 'POST', path, undefined, undefined, {prefix: ''}, ); }; // 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. * @param {Number=} opts.clientWellKnownPollPeriod The number of seconds between polls * to /.well-known/matrix/client, undefined to disable. This should be in the order of hours. * Default: undefined. */ 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, }; } // Create our own user object artificially (instead of waiting for sync) // so it's always available, even if the user is not in any rooms etc. const userId = this.getUserId(); if (userId) { this.store.storeUser(new User(userId)); } if (this._crypto) { this._crypto.uploadDeviceKeys(); this._crypto.start(); } // periodically poll for turn servers if we support voip if (this._supportsVoip) { this._checkTurnServersIntervalID = setInterval(() => { this._checkTurnServers(); }, TURN_CHECK_INTERVAL); this._checkTurnServers(); } 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(); if (opts.clientWellKnownPollPeriod !== undefined) { this._clientWellKnownIntervalID = setInterval(() => { this._fetchClientWellKnown(); }, 1000 * opts.clientWellKnownPollPeriod); this._fetchClientWellKnown(); } }; MatrixClient.prototype._fetchClientWellKnown = async function() { // `getRawClientConfig` does not throw or reject on network errors, instead // it absorbs errors and returns `{}`. this._clientWellKnownPromise = AutoDiscovery.getRawClientConfig( this.getDomain(), ); this._clientWellKnown = await this._clientWellKnownPromise; this.emit("WellKnown.client", this._clientWellKnown); }; MatrixClient.prototype.getClientWellKnown = function() { return this._clientWellKnown; }; MatrixClient.prototype.waitForClientWellKnown = function() { return this._clientWellKnownPromise; }; /** * 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); }; /** * Gets a set of room IDs in common with another user * @param {string} userId The userId to check. * @return {Promise} Resolves to a set of rooms * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype._unstable_getSharedRooms = async function(userId) { if (!(await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666"))) { throw Error('Server does not support shared_rooms API'); } const path = utils.encodeUri("/uk.half-shot.msc2666/user/shared_rooms/$userId", { $userId: userId, }); const res = await this._http.authedRequest( undefined, "GET", path, undefined, undefined, {prefix: PREFIX_UNSTABLE}, ); return res.joined; }; /** * 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(); } if (this._callEventHandler) { this._callEventHandler.stop(); this._callEventHandler = null; } global.clearInterval(this._checkTurnServersIntervalID); if (this._clientWellKnownIntervalID !== undefined) { global.clearInterval(this._clientWellKnownIntervalID); } }; /** * Get the API versions supported by the server, along with any * unstable APIs it supports * @return {Promise} The server /versions response */ MatrixClient.prototype.getVersions = function() { if (this._serverVersionsPromise) { return this._serverVersionsPromise; } this._serverVersionsPromise = this._http.request( undefined, // callback "GET", "/_matrix/client/versions", undefined, // queryParams undefined, // data { prefix: '', }, ).catch((e) => { // Need to unset this if it fails, otherwise we'll never retry this._serverVersionsPromise = null; // but rethrow the exception to anything that was waiting throw e; }); return this._serverVersionsPromise; }; /** * Check if a particular spec version is supported by the server. * @param {string} version The spec version (such as "r0.5.0") to check for. * @return {Promise} Whether it is supported */ MatrixClient.prototype.isVersionSupported = async function(version) { const { versions } = await this.getVersions(); return versions && versions.includes(version); }; /** * Query the server to see if it support members lazy loading * @return {Promise} true if server supports lazy loading */ MatrixClient.prototype.doesServerSupportLazyLoading = async function() { const response = await this.getVersions(); if (!response) return false; const versions = response["versions"]; const unstableFeatures = response["unstable_features"]; return (versions && versions.includes("r0.5.0")) || (unstableFeatures && unstableFeatures["m.lazy_load_members"]); }; /** * Query the server to see if the `id_server` parameter is required * when registering with an 3pid, adding a 3pid or resetting password. * @return {Promise} true if id_server parameter is required */ MatrixClient.prototype.doesServerRequireIdServerParam = async function() { const response = await this.getVersions(); if (!response) return true; const versions = response["versions"]; // Supporting r0.6.0 is the same as having the flag set to false if (versions && versions.includes("r0.6.0")) { return false; } const unstableFeatures = response["unstable_features"]; if (!unstableFeatures) return true; if (unstableFeatures["m.require_identity_server"] === undefined) { return true; } else { return unstableFeatures["m.require_identity_server"]; } }; /** * Query the server to see if the `id_access_token` parameter can be safely * passed to the homeserver. Some homeservers may trigger errors if they are not * prepared for the new parameter. * @return {Promise} true if id_access_token can be sent */ MatrixClient.prototype.doesServerAcceptIdentityAccessToken = async function() { const response = await this.getVersions(); if (!response) return false; const versions = response["versions"]; const unstableFeatures = response["unstable_features"]; return (versions && versions.includes("r0.6.0")) || (unstableFeatures && unstableFeatures["m.id_access_token"]); }; /** * Query the server to see if it supports separate 3PID add and bind functions. * This affects the sequence of API calls clients should use for these operations, * so it's helpful to be able to check for support. * @return {Promise} true if separate functions are supported */ MatrixClient.prototype.doesServerSupportSeparateAddAndBind = async function() { const response = await this.getVersions(); if (!response) return false; const versions = response["versions"]; const unstableFeatures = response["unstable_features"]; return (versions && versions.includes("r0.6.0")) || (unstableFeatures && unstableFeatures["m.separate_add_and_bind"]); }; /** * Query the server to see if it lists support for an unstable feature * in the /versions response * @param {string} feature the feature name * @return {Promise} true if the feature is supported */ MatrixClient.prototype.doesServerSupportUnstableFeature = async function(feature) { const response = await this.getVersions(); if (!response) return false; const unstableFeatures = response["unstable_features"]; return unstableFeatures && !!unstableFeatures[feature]; }; /** * Query the server to see if it is forcing encryption to be enabled for * a given room preset, based on the /versions response. * @param {string} presetName The name of the preset to check. * @returns {Promise} true if the server is forcing encryption * for the preset. */ MatrixClient.prototype.doesServerForceEncryptionForPreset = async function(presetName) { const response = await this.getVersions(); if (!response) return false; const unstableFeatures = response["unstable_features"]; return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${presetName}`]; }; /** * 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); } if (originalEvent && relationType === "m.replace") { events = events.filter(e => e.getSender() === originalEvent.getSender()); } return { originalEvent, events, nextBatch: result.next_batch, }; }; function _reject(callback, reject, err) { if (callback) { callback(err); } reject(err); } function _resolve(callback, resolve, res) { if (callback) { callback(null, res); } resolve(res); } function _PojoToMatrixEventMapper(client, options) { const preventReEmit = Boolean(options && options.preventReEmit); function mapper(plainOldJsObject) { const event = new MatrixEvent(plainOldJsObject); if (event.isEncrypted()) { if (!preventReEmit) { client.reEmitter.reEmit(event, [ "Event.decrypted", ]); } event.attemptDecryption(client._crypto); } if (!preventReEmit) { client.reEmitter.reEmit(event, ["Event.replaced"]); } return event; } return mapper; } /** * @param {object} [options] * @param {bool} options.preventReEmit don't reemit events emitted on an event mapped by this mapper on the client * @return {Function} */ MatrixClient.prototype.getEventMapper = function(options = undefined) { return _PojoToMatrixEventMapper(this, options); }; /** * The app may wish to see if we have a key cached without * triggering a user interaction. * @return {object} */ MatrixClient.prototype.getCrossSigningCacheCallbacks = function() { return this._crypto && this._crypto._crossSigningInfo.getCacheCallbacks(); }; // 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); }; // 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 when the trust status of a user changes * If userId is the userId of the logged in user, this indicated a change * in the trust status of the cross-signing data on the account. * * The cross-signing API is currently UNSTABLE and may change without notice. * * @event module:client~MatrixClient#"userTrustStatusChanged" * @param {string} userId the userId of the user in question * @param {UserTrustLevel} trustLevel The new trust level of the user */ /** * Fires when the user's cross-signing keys have changed or cross-signing * has been enabled/disabled. The client can use getStoredCrossSigningForUser * with the user ID of the logged in user to check if cross-signing is * enabled on the account. If enabled, it can test whether the current key * is trusted using with checkUserTrust with the user ID of the logged * in user. The checkOwnCrossSigningTrust function may be used to reconcile * the trust in the account key. * * The cross-signing API is currently UNSTABLE and may change without notice. * * @event module:client~MatrixClient#"crossSigning.keysChanged" */ /** * 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 * @param {MatrixEvent} event The previous account data, if known. * @example * matrixClient.on("accountData", function(event, oldEvent){ * myAccountData[event.type] = event.content; * }); */ /** * Fires whenever the stored devices for a user have changed * @event module:client~MatrixClient#"crypto.devicesUpdated" * @param {String[]} users A list of user IDs that were updated * @param {bool} initialFetch If true, the store was empty (apart * from our own device) and has been seeded. */ /** * Fires whenever the stored devices for a user will be updated * @event module:client~MatrixClient#"crypto.willUpdateDevices" * @param {String[]} users A list of user IDs that will be updated * @param {bool} initialFetch If true, the store is empty (apart * from our own device) and is being seeded. */ /** * 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 {Number} data.timeout the amount of milliseconds that should be waited * before cancelling the request automatically. * @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 secret request has been cancelled. If the client is prompting * the user to ask whether they want to share a secret, the prompt can be * dismissed. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @event module:client~MatrixClient#"crypto.secrets.requestCancelled" * @param {object} data * @param {string} data.user_id The user ID of the client that had requested the secret. * @param {string} data.device_id The device ID of the client that had requested the * secret. * @param {string} data.request_id The ID of the original request. */ /** * Fires when the client .well-known info is fetched. * * @event module:client~MatrixClient#"WellKnown.client" * @param {object} data The JSON object returned by the server */ // 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. */ matrix-js-sdk-9.11.0/src/content-helpers.js000066400000000000000000000054401403504662500205460ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** @module ContentHelpers */ /** * 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}} */ export function makeHtmlMessage(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}} */ export function makeHtmlNotice(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}} */ export function makeHtmlEmote(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}} */ export function makeTextMessage(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}} */ export function makeNotice(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}} */ export function makeEmoteMessage(body) { return { msgtype: "m.emote", body: body, }; } matrix-js-sdk-9.11.0/src/content-repo.js000066400000000000000000000050771403504662500200570ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module content-repo */ import * as utils from "./utils"; /** * 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. */ export function getHttpUriForMxc(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; } matrix-js-sdk-9.11.0/src/crypto/000077500000000000000000000000001403504662500164135ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/crypto/CrossSigning.js000066400000000000000000000705321403504662500213700ustar00rootroot00000000000000/* Copyright 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. */ /** * Cross signing methods * @module crypto/CrossSigning */ import {decodeBase64, encodeBase64, pkSign, pkVerify} from './olmlib'; import {EventEmitter} from 'events'; import {logger} from '../logger'; import {IndexedDBCryptoStore} from '../crypto/store/indexeddb-crypto-store'; import {decryptAES, encryptAES} from './aes'; const KEY_REQUEST_TIMEOUT_MS = 1000 * 60; function publicKeyFromKeyInfo(keyInfo) { // `keys` is an object with { [`ed25519:${pubKey}`]: pubKey } // We assume only a single key, and we want the bare form without type // prefix, so we select the values. return Object.values(keyInfo.keys)[0]; } export class CrossSigningInfo extends EventEmitter { /** * Information about a user's cross-signing keys * * @class * * @param {string} userId the user that the information is about * @param {object} callbacks Callbacks used to interact with the app * Requires getCrossSigningKey and saveCrossSigningKeys * @param {object} cacheCallbacks Callbacks used to interact with the cache */ constructor(userId, callbacks, cacheCallbacks) { super(); // you can't change the userId Object.defineProperty(this, 'userId', { enumerable: true, value: userId, }); this._callbacks = callbacks || {}; this._cacheCallbacks = cacheCallbacks || {}; this.keys = {}; this.firstUse = true; // This tracks whether we've ever verified this user with any identity. // When you verify a user, any devices online at the time that receive // the verifying signature via the homeserver will latch this to true // and can use it in the future to detect cases where the user has // become unverifed later for any reason. this.crossSigningVerifiedBefore = false; } static fromStorage(obj, userId) { const res = new CrossSigningInfo(userId); for (const prop in obj) { if (obj.hasOwnProperty(prop)) { res[prop] = obj[prop]; } } return res; } toStorage() { return { keys: this.keys, firstUse: this.firstUse, crossSigningVerifiedBefore: this.crossSigningVerifiedBefore, }; } /** * Calls the app callback to ask for a private key * * @param {string} type The key type ("master", "self_signing", or "user_signing") * @param {string} expectedPubkey The matching public key or undefined to use * the stored public key for the given key type. * @returns {Array} An array with [ public key, Olm.PkSigning ] */ async getCrossSigningKey(type, expectedPubkey) { const shouldCache = ["master", "self_signing", "user_signing"].indexOf(type) >= 0; if (!this._callbacks.getCrossSigningKey) { throw new Error("No getCrossSigningKey callback supplied"); } if (expectedPubkey === undefined) { expectedPubkey = this.getId(type); } function validateKey(key) { if (!key) return; const signing = new global.Olm.PkSigning(); const gotPubkey = signing.init_with_seed(key); if (gotPubkey === expectedPubkey) { return [gotPubkey, signing]; } signing.free(); } let privkey; if (this._cacheCallbacks.getCrossSigningKeyCache && shouldCache) { privkey = await this._cacheCallbacks .getCrossSigningKeyCache(type, expectedPubkey); } const cacheresult = validateKey(privkey); if (cacheresult) { return cacheresult; } privkey = await this._callbacks.getCrossSigningKey(type, expectedPubkey); const result = validateKey(privkey); if (result) { if (this._cacheCallbacks.storeCrossSigningKeyCache && shouldCache) { await this._cacheCallbacks.storeCrossSigningKeyCache(type, privkey); } return result; } /* No keysource even returned a key */ if (!privkey) { throw new Error( "getCrossSigningKey callback for " + type + " returned falsey", ); } /* We got some keys from the keysource, but none of them were valid */ throw new Error( "Key type " + type + " from getCrossSigningKey callback did not match", ); } /** * Check whether the private keys exist in secret storage. * XXX: This could be static, be we often seem to have an instance when we * want to know this anyway... * * @param {SecretStorage} secretStorage The secret store using account data * @returns {object} map of key name to key info the secret is encrypted * with, or null if it is not present or not encrypted with a trusted * key */ async isStoredInSecretStorage(secretStorage) { // check what SSSS keys have encrypted the master key (if any) const stored = await secretStorage.isStored("m.cross_signing.master", false) || {}; // then check which of those SSSS keys have also encrypted the SSK and USK function intersect(s) { for (const k of Object.keys(stored)) { if (!s[k]) { delete stored[k]; } } } for (const type of ["self_signing", "user_signing"]) { intersect( await secretStorage.isStored(`m.cross_signing.${type}`, false) || {}, ); } return Object.keys(stored).length ? stored : null; } /** * Store private keys in secret storage for use by other devices. This is * typically called in conjunction with the creation of new cross-signing * keys. * * @param {Map} keys The keys to store * @param {SecretStorage} secretStorage The secret store using account data */ static async storeInSecretStorage(keys, secretStorage) { for (const [type, privateKey] of keys) { const encodedKey = encodeBase64(privateKey); await secretStorage.store(`m.cross_signing.${type}`, encodedKey); } } /** * Get private keys from secret storage created by some other device. This * also passes the private keys to the app-specific callback. * * @param {string} type The type of key to get. One of "master", * "self_signing", or "user_signing". * @param {SecretStorage} secretStorage The secret store using account data * @return {Uint8Array} The private key */ static async getFromSecretStorage(type, secretStorage) { const encodedKey = await secretStorage.get(`m.cross_signing.${type}`); if (!encodedKey) { return null; } return decodeBase64(encodedKey); } /** * Check whether the private keys exist in the local key cache. * * @param {string} [type] The type of key to get. One of "master", * "self_signing", or "user_signing". Optional, will check all by default. * @returns {boolean} True if all keys are stored in the local cache. */ async isStoredInKeyCache(type) { const cacheCallbacks = this._cacheCallbacks; if (!cacheCallbacks) return false; const types = type ? [type] : ["master", "self_signing", "user_signing"]; for (const t of types) { if (!await cacheCallbacks.getCrossSigningKeyCache(t)) { return false; } } return true; } /** * Get cross-signing private keys from the local cache. * * @returns {Map} A map from key type (string) to private key (Uint8Array) */ async getCrossSigningKeysFromCache() { const keys = new Map(); const cacheCallbacks = this._cacheCallbacks; if (!cacheCallbacks) return keys; for (const type of ["master", "self_signing", "user_signing"]) { const privKey = await cacheCallbacks.getCrossSigningKeyCache(type); if (!privKey) { continue; } keys.set(type, privKey); } return keys; } /** * Get the ID used to identify the user. This can also be used to test for * the existence of a given key type. * * @param {string} type The type of key to get the ID of. One of "master", * "self_signing", or "user_signing". Defaults to "master". * * @return {string} the ID */ getId(type) { type = type || "master"; if (!this.keys[type]) return null; const keyInfo = this.keys[type]; return publicKeyFromKeyInfo(keyInfo); } /** * Create new cross-signing keys for the given key types. The public keys * will be held in this class, while the private keys are passed off to the * `saveCrossSigningKeys` application callback. * * @param {CrossSigningLevel} level The key types to reset */ async resetKeys(level) { if (!this._callbacks.saveCrossSigningKeys) { throw new Error("No saveCrossSigningKeys callback supplied"); } // If we're resetting the master key, we reset all keys if ( level === undefined || level & CrossSigningLevel.MASTER || !this.keys.master ) { level = ( CrossSigningLevel.MASTER | CrossSigningLevel.USER_SIGNING | CrossSigningLevel.SELF_SIGNING ); } else if (level === 0) { return; } const privateKeys = {}; const keys = {}; let masterSigning; let masterPub; try { if (level & CrossSigningLevel.MASTER) { masterSigning = new global.Olm.PkSigning(); privateKeys.master = masterSigning.generate_seed(); masterPub = masterSigning.init_with_seed(privateKeys.master); keys.master = { user_id: this.userId, usage: ['master'], keys: { ['ed25519:' + masterPub]: masterPub, }, }; } else { [masterPub, masterSigning] = await this.getCrossSigningKey("master"); } if (level & CrossSigningLevel.SELF_SIGNING) { const sskSigning = new global.Olm.PkSigning(); try { privateKeys.self_signing = sskSigning.generate_seed(); const sskPub = sskSigning.init_with_seed(privateKeys.self_signing); keys.self_signing = { user_id: this.userId, usage: ['self_signing'], keys: { ['ed25519:' + sskPub]: sskPub, }, }; pkSign(keys.self_signing, masterSigning, this.userId, masterPub); } finally { sskSigning.free(); } } if (level & CrossSigningLevel.USER_SIGNING) { const uskSigning = new global.Olm.PkSigning(); try { privateKeys.user_signing = uskSigning.generate_seed(); const uskPub = uskSigning.init_with_seed(privateKeys.user_signing); keys.user_signing = { user_id: this.userId, usage: ['user_signing'], keys: { ['ed25519:' + uskPub]: uskPub, }, }; pkSign(keys.user_signing, masterSigning, this.userId, masterPub); } finally { uskSigning.free(); } } Object.assign(this.keys, keys); this._callbacks.saveCrossSigningKeys(privateKeys); } finally { if (masterSigning) { masterSigning.free(); } } } /** * unsets the keys, used when another session has reset the keys, to disable cross-signing */ clearKeys() { this.keys = {}; } setKeys(keys) { const signingKeys = {}; if (keys.master) { if (keys.master.user_id !== this.userId) { const error = "Mismatched user ID " + keys.master.user_id + " in master key from " + this.userId; logger.error(error); throw new Error(error); } if (!this.keys.master) { // this is the first key we've seen, so first-use is true this.firstUse = true; } else if (publicKeyFromKeyInfo(keys.master) !== this.getId()) { // this is a different key, so first-use is false this.firstUse = false; } // otherwise, same key, so no change signingKeys.master = keys.master; } else if (this.keys.master) { signingKeys.master = this.keys.master; } else { throw new Error("Tried to set cross-signing keys without a master key"); } const masterKey = publicKeyFromKeyInfo(signingKeys.master); // verify signatures if (keys.user_signing) { if (keys.user_signing.user_id !== this.userId) { const error = "Mismatched user ID " + keys.master.user_id + " in user_signing key from " + this.userId; logger.error(error); throw new Error(error); } try { pkVerify(keys.user_signing, masterKey, this.userId); } catch (e) { logger.error("invalid signature on user-signing key"); // FIXME: what do we want to do here? throw e; } } if (keys.self_signing) { if (keys.self_signing.user_id !== this.userId) { const error = "Mismatched user ID " + keys.master.user_id + " in self_signing key from " + this.userId; logger.error(error); throw new Error(error); } try { pkVerify(keys.self_signing, masterKey, this.userId); } catch (e) { logger.error("invalid signature on self-signing key"); // FIXME: what do we want to do here? throw e; } } // if everything checks out, then save the keys if (keys.master) { this.keys.master = keys.master; // if the master key is set, then the old self-signing and // user-signing keys are obsolete this.keys.self_signing = null; this.keys.user_signing = null; } if (keys.self_signing) { this.keys.self_signing = keys.self_signing; } if (keys.user_signing) { this.keys.user_signing = keys.user_signing; } } updateCrossSigningVerifiedBefore(isCrossSigningVerified) { // It is critical that this value latches forward from false to true but // never back to false to avoid a downgrade attack. if (!this.crossSigningVerifiedBefore && isCrossSigningVerified) { this.crossSigningVerifiedBefore = true; } } async signObject(data, type) { if (!this.keys[type]) { throw new Error( "Attempted to sign with " + type + " key but no such key present", ); } const [pubkey, signing] = await this.getCrossSigningKey(type); try { pkSign(data, signing, this.userId, pubkey); return data; } finally { signing.free(); } } async signUser(key) { if (!this.keys.user_signing) { logger.info("No user signing key: not signing user"); return; } return this.signObject(key.keys.master, "user_signing"); } async signDevice(userId, device) { if (userId !== this.userId) { throw new Error( `Trying to sign ${userId}'s device; can only sign our own device`, ); } if (!this.keys.self_signing) { logger.info("No self signing key: not signing device"); return; } return this.signObject( { algorithms: device.algorithms, keys: device.keys, device_id: device.deviceId, user_id: userId, }, "self_signing", ); } /** * Check whether a given user is trusted. * * @param {CrossSigningInfo} userCrossSigning Cross signing info for user * * @returns {UserTrustLevel} */ checkUserTrust(userCrossSigning) { // if we're checking our own key, then it's trusted if the master key // and self-signing key match if (this.userId === userCrossSigning.userId && this.getId() && this.getId() === userCrossSigning.getId() && this.getId("self_signing") && this.getId("self_signing") === userCrossSigning.getId("self_signing") ) { return new UserTrustLevel(true, true, this.firstUse); } if (!this.keys.user_signing) { // If there's no user signing key, they can't possibly be verified. // They may be TOFU trusted though. return new UserTrustLevel(false, false, userCrossSigning.firstUse); } let userTrusted; const userMaster = userCrossSigning.keys.master; const uskId = this.getId('user_signing'); try { pkVerify(userMaster, uskId, this.userId); userTrusted = true; } catch (e) { userTrusted = false; } return new UserTrustLevel( userTrusted, userCrossSigning.crossSigningVerifiedBefore, userCrossSigning.firstUse, ); } /** * Check whether a given device is trusted. * * @param {CrossSigningInfo} userCrossSigning Cross signing info for user * @param {module:crypto/deviceinfo} device The device to check * @param {bool} localTrust Whether the device is trusted locally * @param {bool} trustCrossSignedDevices Whether we trust cross signed devices * * @returns {DeviceTrustLevel} */ checkDeviceTrust(userCrossSigning, device, localTrust, trustCrossSignedDevices) { const userTrust = this.checkUserTrust(userCrossSigning); const userSSK = userCrossSigning.keys.self_signing; if (!userSSK) { // if the user has no self-signing key then we cannot make any // trust assertions about this device from cross-signing return new DeviceTrustLevel( false, false, localTrust, trustCrossSignedDevices, ); } const deviceObj = deviceToObject(device, userCrossSigning.userId); try { // if we can verify the user's SSK from their master key... pkVerify(userSSK, userCrossSigning.getId(), userCrossSigning.userId); // ...and this device's key from their SSK... pkVerify( deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId, ); // ...then we trust this device as much as far as we trust the user return DeviceTrustLevel.fromUserTrustLevel( userTrust, localTrust, trustCrossSignedDevices, ); } catch (e) { return new DeviceTrustLevel( false, false, localTrust, trustCrossSignedDevices, ); } } /** * @returns {object} Cache callbacks */ getCacheCallbacks() { return this._cacheCallbacks; } } function deviceToObject(device, userId) { return { algorithms: device.algorithms, keys: device.keys, device_id: device.deviceId, user_id: userId, signatures: device.signatures, }; } export const CrossSigningLevel = { MASTER: 4, USER_SIGNING: 2, SELF_SIGNING: 1, }; /** * Represents the ways in which we trust a user */ export class UserTrustLevel { constructor(crossSigningVerified, crossSigningVerifiedBefore, tofu) { this._crossSigningVerified = crossSigningVerified; this._crossSigningVerifiedBefore = crossSigningVerifiedBefore; this._tofu = tofu; } /** * @returns {bool} true if this user is verified via any means */ isVerified() { return this.isCrossSigningVerified(); } /** * @returns {bool} true if this user is verified via cross signing */ isCrossSigningVerified() { return this._crossSigningVerified; } /** * @returns {bool} true if we ever verified this user before (at least for * the history of verifications observed by this device). */ wasCrossSigningVerified() { return this._crossSigningVerifiedBefore; } /** * @returns {bool} true if this user's key is trusted on first use */ isTofu() { return this._tofu; } } /** * Represents the ways in which we trust a device */ export class DeviceTrustLevel { constructor(crossSigningVerified, tofu, localVerified, trustCrossSignedDevices) { this._crossSigningVerified = crossSigningVerified; this._tofu = tofu; this._localVerified = localVerified; this._trustCrossSignedDevices = trustCrossSignedDevices; } static fromUserTrustLevel(userTrustLevel, localVerified, trustCrossSignedDevices) { return new DeviceTrustLevel( userTrustLevel._crossSigningVerified, userTrustLevel._tofu, localVerified, trustCrossSignedDevices, ); } /** * @returns {bool} true if this device is verified via any means */ isVerified() { return Boolean(this.isLocallyVerified() || ( this._trustCrossSignedDevices && this.isCrossSigningVerified() )); } /** * @returns {bool} true if this device is verified via cross signing */ isCrossSigningVerified() { return this._crossSigningVerified; } /** * @returns {bool} true if this device is verified locally */ isLocallyVerified() { return this._localVerified; } /** * @returns {bool} true if this device is trusted from a user's key * that is trusted on first use */ isTofu() { return this._tofu; } } export function createCryptoStoreCacheCallbacks(store, olmdevice) { return { getCrossSigningKeyCache: async function(type, _expectedPublicKey) { const key = await new Promise((resolve) => { return store.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { store.getSecretStorePrivateKey(txn, resolve, type); }, ); }); if (key && key.ciphertext) { const pickleKey = Buffer.from(olmdevice._pickleKey); const decrypted = await decryptAES(key, pickleKey, type); return decodeBase64(decrypted); } else { return key; } }, storeCrossSigningKeyCache: async function(type, key) { if (!(key instanceof Uint8Array)) { throw new Error( `storeCrossSigningKeyCache expects Uint8Array, got ${key}`, ); } const pickleKey = Buffer.from(olmdevice._pickleKey); key = await encryptAES(encodeBase64(key), pickleKey, type); return store.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { store.storeSecretStorePrivateKey(txn, type, key); }, ); }, }; } /** * Request cross-signing keys from another device during verification. * * @param {module:base-apis~MatrixBaseApis} baseApis base Matrix API interface * @param {string} userId The user ID being verified * @param {string} deviceId The device ID being verified */ export async function requestKeysDuringVerification(baseApis, userId, deviceId) { // If this is a self-verification, ask the other party for keys if (baseApis.getUserId() !== userId) { return; } logger.log("Cross-signing: Self-verification done; requesting keys"); // This happens asynchronously, and we're not concerned about waiting for // it. We return here in order to test. return new Promise((resolve, reject) => { const client = baseApis; const original = client._crypto._crossSigningInfo; // We already have all of the infrastructure we need to validate and // cache cross-signing keys, so instead of replicating that, here we set // up callbacks that request them from the other device and call // CrossSigningInfo.getCrossSigningKey() to validate/cache const crossSigning = new CrossSigningInfo( original.userId, { getCrossSigningKey: async (type) => { logger.debug("Cross-signing: requesting secret", type, deviceId); const { promise } = client.requestSecret( `m.cross_signing.${type}`, [deviceId], ); const result = await promise; const decoded = decodeBase64(result); return Uint8Array.from(decoded); } }, original._cacheCallbacks, ); crossSigning.keys = original.keys; // XXX: get all keys out if we get one key out // https://github.com/vector-im/element-web/issues/12604 // then change here to reject on the timeout // Requests can be ignored, so don't wait around forever const timeout = new Promise((resolve, reject) => { setTimeout( resolve, KEY_REQUEST_TIMEOUT_MS, new Error("Timeout"), ); }); // also request and cache the key backup key const backupKeyPromise = new Promise(async resolve => { const cachedKey = await client._crypto.getSessionBackupPrivateKey(); if (!cachedKey) { logger.info("No cached backup key found. Requesting..."); const secretReq = client.requestSecret( 'm.megolm_backup.v1', [deviceId], ); const base64Key = await secretReq.promise; logger.info("Got key backup key, decoding..."); const decodedKey = decodeBase64(base64Key); logger.info("Decoded backup key, storing..."); client._crypto.storeSessionBackupPrivateKey( Uint8Array.from(decodedKey), ); logger.info("Backup key stored. Starting backup restore..."); const backupInfo = await client.getKeyBackupVersion(); // no need to await for this - just let it go in the bg client.restoreKeyBackupWithCache( undefined, undefined, backupInfo, ).then(() => { logger.info("Backup restored."); }); } resolve(); }); // We call getCrossSigningKey() for its side-effects return Promise.race([ Promise.all([ crossSigning.getCrossSigningKey("master"), crossSigning.getCrossSigningKey("self_signing"), crossSigning.getCrossSigningKey("user_signing"), backupKeyPromise, ]), timeout, ]).then(resolve, reject); }).catch((e) => { logger.warn("Cross-signing: failure while requesting keys:", e); }); } matrix-js-sdk-9.11.0/src/crypto/DeviceList.js000066400000000000000000001065501403504662500210130ustar00rootroot00000000000000/* 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. */ /** * @module crypto/DeviceList * * Manages the list of other users' devices */ import {EventEmitter} from 'events'; import {logger} from '../logger'; import {DeviceInfo} from './deviceinfo'; import {CrossSigningInfo} from './CrossSigning'; import * as olmlib from './olmlib'; import {IndexedDBCryptoStore} from './store/indexeddb-crypto-store'; import {chunkPromises, defer, sleep} from '../utils'; /* 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 class DeviceList extends EventEmitter { constructor(baseApis, cryptoStore, olmDevice, keyDownloadChunkSize = 250) { super(); this._cryptoStore = cryptoStore; // userId -> { // deviceId -> { // [device info] // } // } this._devices = {}; // userId -> { // [key info] // } this._crossSigningInfo = {}; // map of identity keys to the user who owns it this._userByIdentityKey = {}; // which users we are tracking device status for. // userId -> TRACKING_STATUS_* this._deviceTrackingStatus = {}; // loaded from storage in load() // The 'next_batch' sync token at the point the data was writen, // ie. a token representing the point immediately after the // moment represented by the snapshot in the db. this._syncToken = null; this._serialiser = new DeviceListUpdateSerialiser( baseApis, olmDevice, this, ); // userId -> promise this._keyDownloadsInProgressByUser = {}; // Maximum number of user IDs per request to prevent server overload (#1619) this._keyDownloadChunkSize = keyDownloadChunkSize; // Set whenever changes are made other than setting the sync token this._dirty = false; // Promise resolved when device data is saved this._savePromise = null; // Function that resolves the save promise this._resolveSavePromise = null; // The time the save is scheduled for this._savePromiseTime = null; // The timer used to delay the save this._saveTimer = null; // True if we have fetched data from the server or loaded a non-empty // set of device data from the store this._hasFetched = null; } /** * 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._hasFetched = Boolean(deviceData && deviceData.devices); this._devices = deviceData ? deviceData.devices : {}, this._crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {}; 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', this._syncToken); // null out savePromise now (after the delay but before the write), // otherwise we could return the existing promise when the save has // actually already happened. this._savePromiseTime = null; this._saveTimer = null; this._savePromise = null; this._resolveSavePromise = null; this._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { this._cryptoStore.storeEndToEndDeviceData({ devices: this._devices, crossSigningInfo: this._crossSigningInfo, trackingStatus: this._deviceTrackingStatus, syncToken: this._syncToken, }, txn); }, ).then(() => { // The device list is considered dirty until the write // completes. this._dirty = false; resolveSavePromise(); }, err => { logger.error('Failed to save device tracking data', this._syncToken); logger.error(err); }); }, 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; } /** * Returns a list of all user IDs the DeviceList knows about * * @return {array} All known user IDs */ getKnownUserIds() { return Object.keys(this._devices); } /** * 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]; } getStoredCrossSigningForUser(userId) { if (!this._crossSigningInfo[userId]) return null; return CrossSigningInfo.fromStorage(this._crossSigningInfo[userId], userId); } storeCrossSigningForUser(userId, info) { this._crossSigningInfo[userId] = info; this._dirty = true; } /** * 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); } /** * Get a user ID by one of their device's curve25519 identity key * * @param {string} algorithm encryption algorithm * @param {string} senderKey curve25519 key to match * * @return {string} user ID */ getUserByIdentityKey(algorithm, senderKey) { if ( algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.MEGOLM_ALGORITHM ) { // we only deal in olm keys return null; } return this._userByIdentityKey[senderKey]; } /** * 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.getUserByIdentityKey(algorithm, senderKey); if (!userId) { 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; } } setRawStoredCrossSigningForUser(userId, info) { this._crossSigningInfo[userId] = info; } /** * Fire off download update requests for the given users, and update the * device list tracking status for them, and the * _keyDownloadsInProgressByUser map for them. * * @param {String[]} users list of userIds * * @return {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) => { this.emit("crypto.willUpdateDevices", users, !this._hasFetched); 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(); this.emit("crypto.devicesUpdated", users, !this._hasFetched); this._hasFetched = true; }; 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 {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 = 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; } const factories = []; for (let i = 0; i < downloadUsers.length; i += this._deviceList._keyDownloadChunkSize) { const userSlice = downloadUsers.slice(i, i + this._deviceList._keyDownloadChunkSize); factories.push(() => this._baseApis.downloadKeysForUsers(userSlice, opts)); } chunkPromises(factories, 3).then(async (responses) => { const dk = Object.assign({}, ...(responses.map(res => res.device_keys || {}))); const masterKeys = Object.assign({}, ...(responses.map(res => res.master_keys || {}))); const ssks = Object.assign({}, ...(responses.map(res => res.self_signing_keys || {}))); const usks = Object.assign({}, ...(responses.map(res => res.user_signing_keys || {}))); // yield to other things that want to execute in between users, to // avoid wedging the CPU // (https://github.com/vector-im/element-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. for (const userId of downloadUsers) { await sleep(5); try { await this._processQueryResponseForUser( userId, dk[userId], { master: masterKeys[userId], self_signing: ssks[userId], user_signing: usks[userId], }, ); } catch (e) { // log the error but continue, so that one bad key // doesn't kill the whole process logger.error(`Error processing keys for ${userId}:`, e); } } }).then(() => { 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, dkResponse, crossSigningResponse, ) { logger.log('got device keys for ' + userId + ':', dkResponse); logger.log('got cross-signing keys for ' + userId + ':', crossSigningResponse); { // map from deviceid -> deviceinfo for this user const userStore = {}; const devs = this._deviceList.getRawStoredDevicesForUser(userId); if (devs) { Object.keys(devs).forEach((deviceId) => { const d = DeviceInfo.fromStorage(devs[deviceId], deviceId); userStore[deviceId] = d; }); } await _updateStoredDeviceKeysForUser( this._olmDevice, userId, userStore, dkResponse || {}, this._baseApis.getUserId(), this._baseApis.deviceId, ); // put the updates into the object that will be returned as our results const storage = {}; Object.keys(userStore).forEach((deviceId) => { storage[deviceId] = userStore[deviceId].toStorage(); }); this._deviceList._setRawStoredDevicesForUser(userId, storage); } // now do the same for the cross-signing keys { // FIXME: should we be ignoring empty cross-signing responses, or // should we be dropping the keys? if (crossSigningResponse && (crossSigningResponse.master || crossSigningResponse.self_signing || crossSigningResponse.user_signing)) { const crossSigning = this._deviceList.getStoredCrossSigningForUser(userId) || new CrossSigningInfo(userId); crossSigning.setKeys(crossSigningResponse); this._deviceList.setRawStoredCrossSigningForUser( userId, crossSigning.toStorage(), ); // NB. Unlike most events in the js-sdk, this one is internal to the // js-sdk and is not re-emitted this._deviceList.emit('userCrossSigningUpdated', userId); } } } } async function _updateStoredDeviceKeysForUser( _olmDevice, userId, userStore, userResult, localUserId, localDeviceId, ) { 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)) { if (userId === localUserId && deviceId === localDeviceId) { logger.warn( `Local device ${deviceId} missing from sync, skipping removal`, ); continue; } 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 || {}; const signatures = deviceResult.signatures || {}; 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; deviceStore.signatures = signatures; return true; } matrix-js-sdk-9.11.0/src/crypto/EncryptionSetup.js000066400000000000000000000301551403504662500221300ustar00rootroot00000000000000import { logger } from "../logger"; import {MatrixEvent} from "../models/event"; import {EventEmitter} from "events"; import {createCryptoStoreCacheCallbacks} from "./CrossSigning"; import {IndexedDBCryptoStore} from './store/indexeddb-crypto-store'; import { PREFIX_UNSTABLE, } from "../http-api"; /** * Builds an EncryptionSetupOperation by calling any of the add.. methods. * Once done, `buildOperation()` can be called which allows to apply to operation. * * This is used as a helper by Crypto to keep track of all the network requests * and other side-effects of bootstrapping, so it can be applied in one go (and retried in the future) * Also keeps track of all the private keys created during bootstrapping, so we don't need to prompt for them * more than once. */ export class EncryptionSetupBuilder { /** * @param {Object.} accountData pre-existing account data, will only be read, not written. * @param {CryptoCallbacks} delegateCryptoCallbacks crypto callbacks to delegate to if the key isn't in cache yet */ constructor(accountData, delegateCryptoCallbacks) { this.accountDataClientAdapter = new AccountDataClientAdapter(accountData); this.crossSigningCallbacks = new CrossSigningCallbacks(); this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks); this._crossSigningKeys = null; this._keySignatures = null; this._keyBackupInfo = null; } /** * Adds new cross-signing public keys * * @param {function} authUpload Function called to await an interactive auth * flow when uploading device signing keys. * Args: * {function} A function that makes the request requiring auth. Receives * the auth data as an object. Can be called multiple times, first with * an empty authDict, to obtain the flows. * @param {Object} keys the new keys */ addCrossSigningKeys(authUpload, keys) { this._crossSigningKeys = {authUpload, keys}; } /** * Adds the key backup info to be updated on the server * * Used either to create a new key backup, or add signatures * from the new MSK. * * @param {Object} keyBackupInfo as received from/sent to the server */ addSessionBackup(keyBackupInfo) { this._keyBackupInfo = keyBackupInfo; } /** * Adds the session backup private key to be updated in the local cache * * Used after fixing the format of the key * * @param {Uint8Array} privateKey */ addSessionBackupPrivateKeyToCache(privateKey) { this._sessionBackupPrivateKey = privateKey; } /** * Add signatures from a given user and device/x-sign key * Used to sign the new cross-signing key with the device key * * @param {String} userId * @param {String} deviceId * @param {String} signature */ addKeySignature(userId, deviceId, signature) { if (!this._keySignatures) { this._keySignatures = {}; } const userSignatures = this._keySignatures[userId] || {}; this._keySignatures[userId] = userSignatures; userSignatures[deviceId] = signature; } /** * @param {String} type * @param {Object} content * @return {Promise} */ setAccountData(type, content) { return this.accountDataClientAdapter.setAccountData(type, content); } /** * builds the operation containing all the parts that have been added to the builder * @return {EncryptionSetupOperation} */ buildOperation() { const accountData = this.accountDataClientAdapter._values; return new EncryptionSetupOperation( accountData, this._crossSigningKeys, this._keyBackupInfo, this._keySignatures, ); } /** * Stores the created keys locally. * * This does not yet store the operation in a way that it can be restored, * but that is the idea in the future. * * @param {Crypto} crypto * @return {Promise} */ async persist(crypto) { // store private keys in cache if (this._crossSigningKeys) { const cacheCallbacks = createCryptoStoreCacheCallbacks( crypto._cryptoStore, crypto._olmDevice); for (const type of ["master", "self_signing", "user_signing"]) { logger.log(`Cache ${type} cross-signing private key locally`); const privateKey = this.crossSigningCallbacks.privateKeys.get(type); await cacheCallbacks.storeCrossSigningKeyCache(type, privateKey); } // store own cross-sign pubkeys as trusted await crypto._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { crypto._cryptoStore.storeCrossSigningKeys( txn, this._crossSigningKeys.keys); }, ); } // store session backup key in cache if (this._sessionBackupPrivateKey) { await crypto.storeSessionBackupPrivateKey(this._sessionBackupPrivateKey); } } } /** * Can be created from EncryptionSetupBuilder, or * (in a follow-up PR, not implemented yet) restored from storage, to retry. * * It does not have knowledge of any private keys, unlike the builder. */ export class EncryptionSetupOperation { /** * @param {Map} accountData * @param {Object} crossSigningKeys * @param {Object} keyBackupInfo * @param {Object} keySignatures */ constructor(accountData, crossSigningKeys, keyBackupInfo, keySignatures) { this._accountData = accountData; this._crossSigningKeys = crossSigningKeys; this._keyBackupInfo = keyBackupInfo; this._keySignatures = keySignatures; } /** * Runs the (remaining part of, in the future) operation by sending requests to the server. * @param {Crypto} crypto */ async apply(crypto) { const baseApis = crypto._baseApis; // upload cross-signing keys if (this._crossSigningKeys) { const keys = {}; for (const [name, key] of Object.entries(this._crossSigningKeys.keys)) { keys[name + "_key"] = key; } // We must only call `uploadDeviceSigningKeys` from inside this auth // helper to ensure we properly handle auth errors. await this._crossSigningKeys.authUpload(authDict => { return baseApis.uploadDeviceSigningKeys(authDict, keys); }); // pass the new keys to the main instance of our own CrossSigningInfo. crypto._crossSigningInfo.setKeys(this._crossSigningKeys.keys); } // set account data if (this._accountData) { for (const [type, content] of this._accountData) { await baseApis.setAccountData(type, content); } } // upload first cross-signing signatures with the new key // (e.g. signing our own device) if (this._keySignatures) { await baseApis.uploadKeySignatures(this._keySignatures); } // need to create/update key backup info if (this._keyBackupInfo) { if (this._keyBackupInfo.version) { // session backup signature // The backup is trusted because the user provided the private key. // Sign the backup with the cross signing key so the key backup can // be trusted via cross-signing. await baseApis._http.authedRequest( undefined, "PUT", "/room_keys/version/" + this._keyBackupInfo.version, undefined, { algorithm: this._keyBackupInfo.algorithm, auth_data: this._keyBackupInfo.auth_data, }, {prefix: PREFIX_UNSTABLE}, ); } else { // add new key backup await baseApis._http.authedRequest( undefined, "POST", "/room_keys/version", undefined, this._keyBackupInfo, {prefix: PREFIX_UNSTABLE}, ); } } } } /** * Catches account data set by SecretStorage during bootstrapping by * implementing the methods related to account data in MatrixClient */ class AccountDataClientAdapter extends EventEmitter { /** * @param {Object.} accountData existing account data */ constructor(accountData) { super(); this._existingValues = accountData; this._values = new Map(); } /** * @param {String} type * @return {Promise} the content of the account data */ getAccountDataFromServer(type) { return Promise.resolve(this.getAccountData(type)); } /** * @param {String} type * @return {Object} the content of the account data */ getAccountData(type) { const modifiedValue = this._values.get(type); if (modifiedValue) { return modifiedValue; } const existingValue = this._existingValues[type]; if (existingValue) { return existingValue.getContent(); } return null; } /** * @param {String} type * @param {Object} content * @return {Promise} */ setAccountData(type, content) { const lastEvent = this._values.get(type); this._values.set(type, content); // ensure accountData is emitted on the next tick, // as SecretStorage listens for it while calling this method // and it seems to rely on this. return Promise.resolve().then(() => { const event = new MatrixEvent({type, content}); this.emit("accountData", event, lastEvent); }); } } /** * Catches the private cross-signing keys set during bootstrapping * by both cache callbacks (see createCryptoStoreCacheCallbacks) as non-cache callbacks. * See CrossSigningInfo constructor */ class CrossSigningCallbacks { constructor() { this.privateKeys = new Map(); } // cache callbacks getCrossSigningKeyCache(type, expectedPublicKey) { return this.getCrossSigningKey(type, expectedPublicKey); } storeCrossSigningKeyCache(type, key) { this.privateKeys.set(type, key); return Promise.resolve(); } // non-cache callbacks getCrossSigningKey(type, _expectedPubkey) { return Promise.resolve(this.privateKeys.get(type)); } saveCrossSigningKeys(privateKeys) { for (const [type, privateKey] of Object.entries(privateKeys)) { this.privateKeys.set(type, privateKey); } } } /** * Catches the 4S private key set during bootstrapping by implementing * the SecretStorage crypto callbacks */ class SSSSCryptoCallbacks { constructor(delegateCryptoCallbacks) { this._privateKeys = new Map(); this._delegateCryptoCallbacks = delegateCryptoCallbacks; } async getSecretStorageKey({ keys }, name) { for (const keyId of Object.keys(keys)) { const privateKey = this._privateKeys.get(keyId); if (privateKey) { return [keyId, privateKey]; } } // if we don't have the key cached yet, ask // for it to the general crypto callbacks and cache it if (this._delegateCryptoCallbacks) { const result = await this._delegateCryptoCallbacks. getSecretStorageKey({keys}, name); if (result) { const [keyId, privateKey] = result; this._privateKeys.set(keyId, privateKey); } return result; } } addPrivateKey(keyId, keyInfo, privKey) { this._privateKeys.set(keyId, privKey); // Also pass along to application to cache if it wishes if ( this._delegateCryptoCallbacks && this._delegateCryptoCallbacks.cacheSecretStorageKey ) { this._delegateCryptoCallbacks.cacheSecretStorageKey(keyId, keyInfo, privKey); } } } matrix-js-sdk-9.11.0/src/crypto/OlmDevice.js000066400000000000000000001465051403504662500206330ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2017, 2019 New Vector Ltd Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {logger} from '../logger'; import {IndexedDBCryptoStore} from './store/indexeddb-crypto-store'; import * as algorithms from './algorithms'; // 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. const err = new Error("Message too long (" + payloadString.length + " bytes). " + "The maximum for an encrypted message is " + MAX_PLAINTEXT_LENGTH + " bytes."); // TODO: [TypeScript] We should have our own error types err.data = { errcode: "M_TOO_LARGE", error: "Payload too large for encrypted message", }; throw err; } } /** * 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 */ export 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 = {}; // Used by olm to serialise prekey message decryptions this._olmPrekeyPromise = Promise.resolve(); } /** * Initialise the OlmAccount. This must be called before any other operations * on the OlmDevice. * * Data from an exported Olm device can be provided * in order to re-create this device. * * Attempts to load the OlmAccount from the crypto store, or creates one if none is * found. * * Reads the device keys from the OlmAccount object. * * @param {object} opts * @param {object} opts.fromExportedDevice (Optional) data from exported device * that must be re-created. * If present, opts.pickleKey is ignored * (exported data already provides a pickle key) * @param {object} opts.pickleKey (Optional) pickle key to set instead of default one */ OlmDevice.prototype.init = async function(opts = {}) { let e2eKeys; const account = new global.Olm.Account(); const { pickleKey, fromExportedDevice } = opts; try { if (fromExportedDevice) { if (pickleKey) { logger.warn( 'ignoring opts.pickleKey' + ' because opts.fromExportedDevice is present.', ); } this._pickleKey = fromExportedDevice.pickleKey; await _initialiseFromExportedDevice( fromExportedDevice, this._cryptoStore, this._pickleKey, account, ); } else { if (pickleKey) { this._pickleKey = pickleKey; } 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; }; /** * Populates the crypto store using data that was exported from an existing device. * Note that for now only the “account” and “sessions” stores are populated; * Other stores will be as with a new device. * * @param {Object} exportedData Data exported from another device * through the “export” method. * @param {module:crypto/store/base~CryptoStore} cryptoStore storage for the crypto layer * @param {string} pickleKey the key that was used to pickle the exported data * @param {Olm.Account} account an olm account to initialize */ async function _initialiseFromExportedDevice( exportedData, cryptoStore, pickleKey, account, ) { await cryptoStore.doTxn( 'readwrite', [ IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS, ], (txn) => { cryptoStore.storeAccount(txn, exportedData.pickledAccount); exportedData.sessions.forEach((session) => { const { deviceKey, sessionId, } = session; const sessionInfo = { session: session.session, lastReceivedMessageTs: session.lastReceivedMessageTs, }; cryptoStore.storeEndToEndSession( deviceKey, sessionId, sessionInfo, txn, ); }); }); account.unpickle(pickleKey, exportedData.pickledAccount); } 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)); }; /** * Export data for re-creating the Olm device later. * TODO export data other than just account and (P2P) sessions. * * @return {Promise} The exported data */ OlmDevice.prototype.export = async function() { const result = { pickleKey: this._pickleKey, }; await this._cryptoStore.doTxn( 'readonly', [ IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS, ], (txn) => { this._cryptoStore.getAccount(txn, (pickledAccount) => { result.pickledAccount = pickledAccount; }); result.sessions = []; // Note that the pickledSession object we get in the callback // is not exactly the same thing you get in method _getSession // see documentation of IndexedDBCryptoStore.getAllEndToEndSessions this._cryptoStore.getAllEndToEndSessions(txn, (pickledSession) => { result.sessions.push(pickledSession); }); }, ); return result; }; /** * 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 fallback keys * * @return {Promise} Resolved once the account is saved back having generated the key */ OlmDevice.prototype.generateFallbackKey = async function() { await this._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { this._getAccount(txn, (account) => { account.generate_fallback_key(); this._storeAccount(txn, account); }); }, ); }; OlmDevice.prototype.getFallbackKey = async function() { let result; await this._cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { this._getAccount(txn, (account) => { result = JSON.parse(account.fallback_key()); }); }, ); return result; }; /** * 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(); } }); }, logger.withPrefix("[createOutboundSession]"), ); 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(); } }); }, logger.withPrefix("[createInboundSession]"), ); 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) { const log = logger.withPrefix("[getSessionIdsForDevice]"); if (this._sessionsInProgress[theirDeviceIdentityKey]) { log.debug(`Waiting for Olm session for ${theirDeviceIdentityKey} 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); }, ); }, log, ); 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. * @param {Logger} [log] A possibly customised log * @return {Promise} session id, or null if no established session */ OlmDevice.prototype.getSessionIdForDevice = async function( theirDeviceIdentityKey, nowait, log, ) { const sessionInfos = await this.getSessionInfoForDevice( theirDeviceIdentityKey, nowait, log, ); 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. * @param {Logger} [log] A possibly customised log * @return {Array.<{sessionId: string, hasReceivedMessage: Boolean}>} */ OlmDevice.prototype.getSessionInfoForDevice = async function( deviceIdentityKey, nowait, log = logger, ) { log = log.withPrefix("[getSessionInfoForDevice]"); if (this._sessionsInProgress[deviceIdentityKey] && !nowait) { log.debug(`Waiting for Olm session for ${deviceIdentityKey} 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, }); }); } }); }, log, ); 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) => { const sessionDesc = sessionInfo.session.describe(); logger.log( "encryptMessage: Olm Session ID " + sessionId + " to " + theirDeviceIdentityKey + ": " + sessionDesc, ); res = sessionInfo.session.encrypt(payloadString); this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); }); }, logger.withPrefix("[encryptMessage]"), ); 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) => { const sessionDesc = sessionInfo.session.describe(); logger.log( "decryptMessage: Olm Session ID " + sessionId + " from " + theirDeviceIdentityKey + ": " + sessionDesc, ); payloadString = sessionInfo.session.decrypt(messageType, ciphertext); sessionInfo.lastReceivedMessageTs = Date.now(); this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); }); }, logger.withPrefix("[decryptMessage]"), ); 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); }); }, logger.withPrefix("[matchesSession]"), ); return matches; }; OlmDevice.prototype.recordSessionProblem = async function(deviceKey, type, fixed) { await this._cryptoStore.storeEndToEndSessionProblem(deviceKey, type, fixed); }; OlmDevice.prototype.sessionMayHaveProblems = async function(deviceKey, timestamp) { return await this._cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp); }; OlmDevice.prototype.filterOutNotifiedErrorDevices = async function(devices) { return await this._cryptoStore.filterOutNotifiedErrorDevices(devices); }; // 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; logger.log(`encrypting msg with megolm session ${sessionId}`); 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, withheld) => { if (sessionData === null) { func(null, null, withheld); 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, withheld); }); }, ); }; /** * 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) * @param {Object} [extraSessionData={}] any other data to be include with the session */ OlmDevice.prototype.addInboundGroupSession = async function( roomId, senderKey, forwardingCurve25519KeyChain, sessionId, sessionKey, keysClaimed, exportFormat, extraSessionData = {}, ) { await this._cryptoStore.doTxn( 'readwrite', [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, IndexedDBCryptoStore.STORE_SHARED_HISTORY_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() && !(existingSession.first_known_index() == session.first_known_index() && !extraSessionData.untrusted && existingSessionData.untrusted)) { // existing session has lower index (i.e. can // decrypt more), or they have the same index and // the new sessions trust does not win over the old // sessions trust, so keep it logger.log( `Keeping existing megolm session ${sessionId}`, ); return; } } logger.info( "Storing megolm session " + senderKey + "/" + sessionId + " with first index " + session.first_known_index(), ); const sessionData = Object.assign({}, extraSessionData, { room_id: roomId, session: session.pickle(this._pickleKey), keysClaimed: keysClaimed, forwardingCurve25519KeyChain: forwardingCurve25519KeyChain, }); this._cryptoStore.storeEndToEndInboundGroupSession( senderKey, sessionId, sessionData, txn, ); if (!existingSession && extraSessionData.sharedHistory) { this._cryptoStore.addSharedHistoryInboundGroupSession( roomId, senderKey, sessionId, txn, ); } } finally { session.free(); } }, ); }, logger.withPrefix("[addInboundGroupSession]"), ); }; /** * Record in the data store why an inbound group session was withheld. * * @param {string} roomId room that the session belongs to * @param {string} senderKey base64-encoded curve25519 key of the sender * @param {string} sessionId session identifier * @param {string} code reason code * @param {string} reason human-readable version of `code` */ OlmDevice.prototype.addInboundGroupSessionWithheld = async function( roomId, senderKey, sessionId, code, reason, ) { await this._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], (txn) => { this._cryptoStore.storeEndToEndInboundGroupSessionWithheld( senderKey, sessionId, { room_id: roomId, code: code, reason: reason, }, txn, ); }, ); }; export const WITHHELD_MESSAGES = { "m.unverified": "The sender has disabled encrypting to unverified devices.", "m.blacklisted": "The sender has blocked you.", "m.unauthorised": "You are not authorised to read the message.", "m.no_olm": "Unable to establish a secure channel.", }; /** * Calculate the message to use for the exception when a session key is withheld. * * @param {object} withheld An object that describes why the key was withheld. * * @return {string} the message * * @private */ function _calculateWithheldMessage(withheld) { if (withheld.code && withheld.code in WITHHELD_MESSAGES) { return WITHHELD_MESSAGES[withheld.code]; } else if (withheld.reason) { return withheld.reason; } else { return "decryption key withheld"; } } /** * 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; // when the localstorage crypto store is used as an indexeddb backend, // exceptions thrown from within the inner function are not passed through // to the top level, so we store exceptions in a variable and raise them at // the end let error; await this._cryptoStore.doTxn( 'readwrite', [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, ], (txn) => { this._getInboundGroupSession( roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => { if (session === null) { if (withheld) { error = new algorithms.DecryptionError( "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", _calculateWithheldMessage(withheld), { session: senderKey + '|' + sessionId, }, ); } result = null; return; } let res; try { res = session.decrypt(body); } catch (e) { if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX' && withheld) { error = new algorithms.DecryptionError( "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", _calculateWithheldMessage(withheld), { session: senderKey + '|' + sessionId, }, ); } else { error = e; } return; } 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 ) { error = new Error( "Duplicate message index, possible replay attack: " + messageIndexKey, ); return; } } 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 || [] ), untrusted: sessionData.untrusted, }; }, ); }, logger.withPrefix("[decryptGroupMessage]"), ); if (error) { throw error; } 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 {string} 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, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, ], (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; } }, ); }, logger.withPrefix("[hasInboundSessionKeys]"), ); 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, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, ], (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, "shared_history": sessionData.sharedHistory || false, }; }, ); }, logger.withPrefix("[getInboundGroupSessionKey]"), ); 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(), "org.matrix.msc3061.shared_history": sessionData.sharedHistory || false, }; }); }; OlmDevice.prototype.getSharedHistoryInboundGroupSessions = async function(roomId) { let result; await this._cryptoStore.doTxn( 'readonly', [ IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS, ], (txn) => { result = this._cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn); }, logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]"), ); return result; }; // 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); }); }; matrix-js-sdk-9.11.0/src/crypto/OutgoingRoomKeyRequestManager.js000066400000000000000000000477561403504662500247410ustar00rootroot00000000000000/* 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 '../logger'; import * as 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} */ export 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 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; } /** * 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 any requests that have been queued */ sendQueuedRequests() { this._startTimer(); } /** * Queue up a room key request, if we haven't already queued or sent one. * * 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 queueRoomKeyRequest(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.queueRoomKeyRequest( requestBody, recipients, resend, ); } // We don't want to wait for the timer, so we send it // immediately. (We might actually end up racing with the timer, // but that's ok: even if we make the request twice, we'll do it // with the same transaction_id, so only one message will get // sent). // // (We also don't want to wait for the response from the server // here, as it will slow down processing of received keys if we // do.) try { await this._sendOutgoingRoomKeyRequestCancellation( updatedReq, true, ); } catch (e) { logger.error( "Error sending room key request cancellation;" + " will retry later.", e, ); } // The request has transitioned from // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We // still need to resend the request which is now UNSENT, so // start the timer if it isn't already started. } break; } default: throw new Error('unhandled state: ' + req.state); } } } /** * 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], ); } /** * Find anything in `sent` state, and kick it around the loop again. * This is intended for situations where something substantial has changed, and we * don't really expect the other end to even care about the cancellation. * For example, after initialization or self-verification. * @return {Promise} An array of `queueRoomKeyRequest` outputs. */ async cancelAndResendAllOutgoingRequests() { const outgoings = await this._cryptoStore.getAllOutgoingRoomKeyRequestsByState( ROOM_KEY_REQUEST_STATES.SENT, ); return Promise.all(outgoings.map(({ requestBody, recipients }) => this.queueRoomKeyRequest(requestBody, recipients, true))); } // start the background timer to send queued requests, if the timer isn't // already running _startTimer() { if (this._sendOutgoingRoomKeyRequestsTimer) { 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(); } 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) { 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; }); }); } // 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(",") + ']'; } matrix-js-sdk-9.11.0/src/crypto/RoomList.js000066400000000000000000000037121403504662500205240ustar00rootroot00000000000000/* 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 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); }, ); } } matrix-js-sdk-9.11.0/src/crypto/SecretStorage.js000066400000000000000000000507311403504662500215310ustar00rootroot00000000000000/* Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {EventEmitter} from 'events'; import {logger} from '../logger'; import * as olmlib from './olmlib'; import {randomString} from '../randomstring'; import {encryptAES, decryptAES} from './aes'; import {encodeBase64} from "./olmlib"; export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2"; const ZERO_STR = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"; /** * Implements Secure Secret Storage and Sharing (MSC1946) * @module crypto/SecretStorage */ export class SecretStorage extends EventEmitter { constructor(baseApis, cryptoCallbacks) { super(); this._baseApis = baseApis; this._cryptoCallbacks = cryptoCallbacks; this._requests = {}; this._incomingRequests = {}; } async getDefaultKeyId() { const defaultKey = await this._baseApis.getAccountDataFromServer( 'm.secret_storage.default_key', ); if (!defaultKey) return null; return defaultKey.key; } setDefaultKeyId(keyId) { return new Promise(async (resolve, reject) => { const listener = (ev) => { if ( ev.getType() === 'm.secret_storage.default_key' && ev.getContent().key === keyId ) { this._baseApis.removeListener('accountData', listener); resolve(); } }; this._baseApis.on('accountData', listener); try { await this._baseApis.setAccountData( 'm.secret_storage.default_key', { key: keyId }, ); } catch (e) { this._baseApis.removeListener('accountData', listener); reject(e); } }); } /** * Add a key for encrypting secrets. * * @param {string} algorithm the algorithm used by the key. * @param {object} opts the options for the algorithm. The properties used * depend on the algorithm given. * @param {string} [keyId] the ID of the key. If not given, a random * ID will be generated. * * @return {object} An object with: * keyId: {string} the ID of the key * keyInfo: {object} details about the key (iv, mac, passphrase) */ async addKey(algorithm, opts, keyId) { const keyInfo = {algorithm}; if (!opts) opts = {}; if (opts.name) { keyInfo.name = opts.name; } if (algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { if (opts.passphrase) { keyInfo.passphrase = opts.passphrase; } if (opts.key) { const {iv, mac} = await SecretStorage._calculateKeyCheck(opts.key); keyInfo.iv = iv; keyInfo.mac = mac; } } else { throw new Error(`Unknown key algorithm ${opts.algorithm}`); } if (!keyId) { do { keyId = randomString(32); } while ( await this._baseApis.getAccountDataFromServer( `m.secret_storage.key.${keyId}`, ) ); } await this._baseApis.setAccountData( `m.secret_storage.key.${keyId}`, keyInfo, ); return { keyId, keyInfo, }; } /** * Get the key information for a given ID. * * @param {string} [keyId = default key's ID] The ID of the key to check * for. Defaults to the default key ID if not provided. * @returns {Array?} If the key was found, the return value is an array of * the form [keyId, keyInfo]. Otherwise, null is returned. */ async getKey(keyId) { if (!keyId) { keyId = await this.getDefaultKeyId(); } if (!keyId) { return null; } const keyInfo = await this._baseApis.getAccountDataFromServer( "m.secret_storage.key." + keyId, ); return keyInfo ? [keyId, keyInfo] : null; } /** * Check whether we have a key with a given ID. * * @param {string} [keyId = default key's ID] The ID of the key to check * for. Defaults to the default key ID if not provided. * @return {boolean} Whether we have the key. */ async hasKey(keyId) { return !!(await this.getKey(keyId)); } /** * Check whether a key matches what we expect based on the key info * * @param {Uint8Array} key the key to check * @param {object} info the key info * * @return {boolean} whether or not the key matches */ async checkKey(key, info) { if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { if (info.mac) { const {mac} = await SecretStorage._calculateKeyCheck(key, info.iv); return info.mac.replace(/=+$/g, '') === mac.replace(/=+$/g, ''); } else { // if we have no information, we have to assume the key is right return true; } } else { throw new Error("Unknown algorithm"); } } static async _calculateKeyCheck(key, iv) { return await encryptAES(ZERO_STR, key, "", iv); } /** * Store an encrypted secret on the server * * @param {string} name The name of the secret * @param {string} secret The secret contents. * @param {Array} keys The IDs of the keys to use to encrypt the secret * or null/undefined to use the default key. */ async store(name, secret, keys) { const encrypted = {}; if (!keys) { const defaultKeyId = await this.getDefaultKeyId(); if (!defaultKeyId) { throw new Error("No keys specified and no default key present"); } keys = [defaultKeyId]; } if (keys.length === 0) { throw new Error("Zero keys given to encrypt with!"); } for (const keyId of keys) { // get key information from key storage const keyInfo = await this._baseApis.getAccountDataFromServer( "m.secret_storage.key." + keyId, ); if (!keyInfo) { throw new Error("Unknown key: " + keyId); } // encrypt secret, based on the algorithm if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { const keys = {[keyId]: keyInfo}; const [, encryption] = await this._getSecretStorageKey(keys, name); encrypted[keyId] = await encryption.encrypt(secret); } else { logger.warn("unknown algorithm for secret storage key " + keyId + ": " + keyInfo.algorithm); // do nothing if we don't understand the encryption algorithm } } // save encrypted secret await this._baseApis.setAccountData(name, {encrypted}); } /** * Temporary method to fix up existing accounts where secrets * are incorrectly stored without the 'encrypted' level * * @param {string} name The name of the secret * @param {object} secretInfo The account data object * @returns {object} The fixed object or null if no fix was performed */ async _fixupStoredSecret(name, secretInfo) { // We assume the secret was only stored passthrough for 1 // key - this was all the broken code supported. const keys = Object.keys(secretInfo); if ( keys.length === 1 && keys[0] !== 'encrypted' && secretInfo[keys[0]].passthrough ) { const hasKey = await this.hasKey(keys[0]); if (hasKey) { logger.log("Fixing up passthrough secret: " + name); await this.storePassthrough(name, keys[0]); const newData = await this._baseApis.getAccountDataFromServer(name); return newData; } } return null; } /** * Get a secret from storage. * * @param {string} name the name of the secret * * @return {string} the contents of the secret */ async get(name) { let secretInfo = await this._baseApis.getAccountDataFromServer(name); if (!secretInfo) { return; } if (!secretInfo.encrypted) { // try to fix it up secretInfo = await this._fixupStoredSecret(name, secretInfo); if (!secretInfo || !secretInfo.encrypted) { throw new Error("Content is not encrypted!"); } } // get possible keys to decrypt const keys = {}; for (const keyId of Object.keys(secretInfo.encrypted)) { // get key information from key storage const keyInfo = await this._baseApis.getAccountDataFromServer( "m.secret_storage.key." + keyId, ); const encInfo = secretInfo.encrypted[keyId]; // only use keys we understand the encryption algorithm of if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { keys[keyId] = keyInfo; } } } if (Object.keys(keys).length === 0) { throw new Error(`Could not decrypt ${name} because none of ` + `the keys it is encrypted with are for a supported algorithm`); } let keyId; let decryption; try { // fetch private key from app [keyId, decryption] = await this._getSecretStorageKey(keys, name); const encInfo = secretInfo.encrypted[keyId]; // We don't actually need the decryption object if it's a passthrough // since we just want to return the key itself. It must be base64 // encoded, since this is how a key would normally be stored. if (encInfo.passthrough) return encodeBase64(decryption.get_private_key()); return await decryption.decrypt(encInfo); } finally { if (decryption && decryption.free) decryption.free(); } } /** * Check if a secret is stored on the server. * * @param {string} name the name of the secret * @param {boolean} checkKey check if the secret is encrypted by a trusted key * * @return {object?} map of key name to key info the secret is encrypted * with, or null if it is not present or not encrypted with a trusted * key */ async isStored(name, checkKey) { // check if secret exists let secretInfo = await this._baseApis.getAccountDataFromServer(name); if (!secretInfo) return null; if (!secretInfo.encrypted) { // try to fix it up secretInfo = await this._fixupStoredSecret(name, secretInfo); if (!secretInfo || !secretInfo.encrypted) { return null; } } if (checkKey === undefined) checkKey = true; const ret = {}; // filter secret encryption keys with supported algorithm for (const keyId of Object.keys(secretInfo.encrypted)) { // get key information from key storage const keyInfo = await this._baseApis.getAccountDataFromServer( "m.secret_storage.key." + keyId, ); if (!keyInfo) continue; const encInfo = secretInfo.encrypted[keyId]; // only use keys we understand the encryption algorithm of if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { if (encInfo.iv && encInfo.ciphertext && encInfo.mac) { ret[keyId] = keyInfo; } } } return Object.keys(ret).length ? ret : null; } /** * Request a secret from another device * * @param {string} name the name of the secret to request * @param {string[]} devices the devices to request the secret from * * @return {string} the contents of the secret */ request(name, devices) { const requestId = this._baseApis.makeTxnId(); const requestControl = this._requests[requestId] = { name, devices, }; const promise = new Promise((resolve, reject) => { requestControl.resolve = resolve; requestControl.reject = reject; }); const cancel = (reason) => { // send cancellation event const cancelData = { action: "request_cancellation", requesting_device_id: this._baseApis.deviceId, request_id: requestId, }; const toDevice = {}; for (const device of devices) { toDevice[device] = cancelData; } this._baseApis.sendToDevice("m.secret.request", { [this._baseApis.getUserId()]: toDevice, }); // and reject the promise so that anyone waiting on it will be // notified requestControl.reject(new Error(reason || "Cancelled")); }; // send request to devices const requestData = { name, action: "request", requesting_device_id: this._baseApis.deviceId, request_id: requestId, }; const toDevice = {}; for (const device of devices) { toDevice[device] = requestData; } logger.info(`Request secret ${name} from ${devices}, id ${requestId}`); this._baseApis.sendToDevice("m.secret.request", { [this._baseApis.getUserId()]: toDevice, }); return { request_id: requestId, promise, cancel, }; } async _onRequestReceived(event) { const sender = event.getSender(); const content = event.getContent(); if (sender !== this._baseApis.getUserId() || !(content.name && content.action && content.requesting_device_id && content.request_id)) { // ignore requests from anyone else, for now return; } const deviceId = content.requesting_device_id; // check if it's a cancel if (content.action === "request_cancellation") { if (this._incomingRequests[deviceId] && this._incomingRequests[deviceId][content.request_id]) { logger.info("received request cancellation for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")"); this.baseApis.emit("crypto.secrets.requestCancelled", { user_id: sender, device_id: deviceId, request_id: content.request_id, }); } } else if (content.action === "request") { if (deviceId === this._baseApis.deviceId) { // no point in trying to send ourself the secret return; } // check if we have the secret logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")"); if (!this._cryptoCallbacks.onSecretRequested) { return; } const secret = await this._cryptoCallbacks.onSecretRequested( sender, deviceId, content.request_id, content.name, this._baseApis.checkDeviceTrust(sender, deviceId), ); if (secret) { logger.info(`Preparing ${content.name} secret for ${deviceId}`); const payload = { type: "m.secret.send", content: { request_id: content.request_id, secret: secret, }, }; const encryptedContent = { algorithm: olmlib.OLM_ALGORITHM, sender_key: this._baseApis._crypto._olmDevice.deviceCurve25519Key, ciphertext: {}, }; await olmlib.ensureOlmSessionsForDevices( this._baseApis._crypto._olmDevice, this._baseApis, { [sender]: [ this._baseApis.getStoredDevice(sender, deviceId), ], }, ); await olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this._baseApis.getUserId(), this._baseApis.deviceId, this._baseApis._crypto._olmDevice, sender, this._baseApis.getStoredDevice(sender, deviceId), payload, ); const contentMap = { [sender]: { [deviceId]: encryptedContent, }, }; logger.info(`Sending ${content.name} secret for ${deviceId}`); this._baseApis.sendToDevice("m.room.encrypted", contentMap); } else { logger.info(`Request denied for ${content.name} secret for ${deviceId}`); } } } _onSecretReceived(event) { if (event.getSender() !== this._baseApis.getUserId()) { // we shouldn't be receiving secrets from anyone else, so ignore // because someone could be trying to send us bogus data return; } const content = event.getContent(); logger.log("got secret share for request", content.request_id); const requestControl = this._requests[content.request_id]; if (requestControl) { // make sure that the device that sent it is one of the devices that // we requested from const deviceInfo = this._baseApis._crypto._deviceList.getDeviceByIdentityKey( olmlib.OLM_ALGORITHM, event.getSenderKey(), ); if (!deviceInfo) { logger.log( "secret share from unknown device with key", event.getSenderKey(), ); return; } if (!requestControl.devices.includes(deviceInfo.deviceId)) { logger.log("unsolicited secret share from device", deviceInfo.deviceId); return; } logger.log( `Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`, ); requestControl.resolve(content.secret); } } async _getSecretStorageKey(keys, name) { if (!this._cryptoCallbacks.getSecretStorageKey) { throw new Error("No getSecretStorageKey callback supplied"); } const returned = await this._cryptoCallbacks.getSecretStorageKey({ keys }, name); if (!returned) { throw new Error("getSecretStorageKey callback returned falsey"); } if (returned.length < 2) { throw new Error("getSecretStorageKey callback returned invalid data"); } const [keyId, privateKey] = returned; if (!keys[keyId]) { throw new Error("App returned unknown key from getSecretStorageKey!"); } if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { const decryption = { encrypt: async function(secret) { return await encryptAES(secret, privateKey, name); }, decrypt: async function(encInfo) { return await decryptAES(encInfo, privateKey, name); }, }; return [keyId, decryption]; } else { throw new Error("Unknown key type: " + keys[keyId].algorithm); } } } matrix-js-sdk-9.11.0/src/crypto/aes.js000066400000000000000000000156201403504662500175250ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {getCrypto} from '../utils'; import {decodeBase64, encodeBase64} from './olmlib'; const subtleCrypto = (typeof window !== "undefined" && window.crypto) ? (window.crypto.subtle || window.crypto.webkitSubtle) : null; // salt for HKDF, with 8 bytes of zeros const zerosalt = new Uint8Array(8); /** * encrypt a string in Node.js * * @param {string} data the plaintext to encrypt * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret * @param {string} ivStr the initialization vector to use */ async function encryptNode(data, key, name, ivStr) { const crypto = getCrypto(); if (!crypto) { throw new Error("No usable crypto implementation"); } let iv; if (ivStr) { iv = decodeBase64(ivStr); } else { iv = crypto.randomBytes(16); } // clear bit 63 of the IV to stop us hitting the 64-bit counter boundary // (which would mean we wouldn't be able to decrypt on Android). The loss // of a single bit of iv is a price we have to pay. iv[8] &= 0x7f; const [aesKey, hmacKey] = deriveKeysNode(key, name); const cipher = crypto.createCipheriv("aes-256-ctr", aesKey, iv); const ciphertext = cipher.update(data, "utf-8", "base64") + cipher.final("base64"); const hmac = crypto.createHmac("sha256", hmacKey) .update(ciphertext, "base64").digest("base64"); return { iv: encodeBase64(iv), ciphertext: ciphertext, mac: hmac, }; } /** * decrypt a string in Node.js * * @param {object} data the encrypted data * @param {string} data.ciphertext the ciphertext in base64 * @param {string} data.iv the initialization vector in base64 * @param {string} data.mac the HMAC in base64 * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret */ async function decryptNode(data, key, name) { const crypto = getCrypto(); if (!crypto) { throw new Error("No usable crypto implementation"); } const [aesKey, hmacKey] = deriveKeysNode(key, name); const hmac = crypto.createHmac("sha256", hmacKey) .update(data.ciphertext, "base64").digest("base64").replace(/=+$/g, ''); if (hmac !== data.mac.replace(/=+$/g, '')) { throw new Error(`Error decrypting secret ${name}: bad MAC`); } const decipher = crypto.createDecipheriv( "aes-256-ctr", aesKey, decodeBase64(data.iv), ); return decipher.update(data.ciphertext, "base64", "utf-8") + decipher.final("utf-8"); } function deriveKeysNode(key, name) { const crypto = getCrypto(); const prk = crypto.createHmac("sha256", zerosalt) .update(key).digest(); const b = Buffer.alloc(1, 1); const aesKey = crypto.createHmac("sha256", prk) .update(name, "utf-8").update(b).digest(); b[0] = 2; const hmacKey = crypto.createHmac("sha256", prk) .update(aesKey).update(name, "utf-8").update(b).digest(); return [aesKey, hmacKey]; } /** * encrypt a string in Node.js * * @param {string} data the plaintext to encrypt * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret * @param {string} ivStr the initialization vector to use */ async function encryptBrowser(data, key, name, ivStr) { let iv; if (ivStr) { iv = decodeBase64(ivStr); } else { iv = new Uint8Array(16); window.crypto.getRandomValues(iv); } // clear bit 63 of the IV to stop us hitting the 64-bit counter boundary // (which would mean we wouldn't be able to decrypt on Android). The loss // of a single bit of iv is a price we have to pay. iv[8] &= 0x7f; const [aesKey, hmacKey] = await deriveKeysBrowser(key, name); const encodedData = new TextEncoder().encode(data); const ciphertext = await subtleCrypto.encrypt( { name: "AES-CTR", counter: iv, length: 64, }, aesKey, encodedData, ); const hmac = await subtleCrypto.sign( {name: 'HMAC'}, hmacKey, ciphertext, ); return { iv: encodeBase64(iv), ciphertext: encodeBase64(ciphertext), mac: encodeBase64(hmac), }; } /** * decrypt a string in the browser * * @param {object} data the encrypted data * @param {string} data.ciphertext the ciphertext in base64 * @param {string} data.iv the initialization vector in base64 * @param {string} data.mac the HMAC in base64 * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret */ async function decryptBrowser(data, key, name) { const [aesKey, hmacKey] = await deriveKeysBrowser(key, name); const ciphertext = decodeBase64(data.ciphertext); if (!await subtleCrypto.verify( {name: "HMAC"}, hmacKey, decodeBase64(data.mac), ciphertext, )) { throw new Error(`Error decrypting secret ${name}: bad MAC`); } const plaintext = await subtleCrypto.decrypt( { name: "AES-CTR", counter: decodeBase64(data.iv), length: 64, }, aesKey, ciphertext, ); return new TextDecoder().decode(new Uint8Array(plaintext)); } async function deriveKeysBrowser(key, name) { const hkdfkey = await subtleCrypto.importKey( 'raw', key, {name: "HKDF"}, false, ["deriveBits"], ); const keybits = await subtleCrypto.deriveBits( { name: "HKDF", salt: zerosalt, info: (new TextEncoder().encode(name)), hash: "SHA-256", }, hkdfkey, 512, ); const aesKey = keybits.slice(0, 32); const hmacKey = keybits.slice(32); const aesProm = subtleCrypto.importKey( 'raw', aesKey, {name: 'AES-CTR'}, false, ['encrypt', 'decrypt'], ); const hmacProm = subtleCrypto.importKey( 'raw', hmacKey, { name: 'HMAC', hash: {name: 'SHA-256'}, }, false, ['sign', 'verify'], ); return await Promise.all([aesProm, hmacProm]); } export function encryptAES(...args) { return subtleCrypto ? encryptBrowser(...args) : encryptNode(...args); } export function decryptAES(...args) { return subtleCrypto ? decryptBrowser(...args) : decryptNode(...args); } matrix-js-sdk-9.11.0/src/crypto/algorithms/000077500000000000000000000000001403504662500205645ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/crypto/algorithms/base.js000066400000000000000000000171131403504662500220370ustar00rootroot00000000000000/* 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 */ /** * map of registered encryption algorithm classes. A map from string to {@link * module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} class * * @type {Object.} */ export const ENCRYPTION_CLASSES = {}; /** * map of registered encryption algorithm classes. Map from string to {@link * module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm} class * * @type {Object.} */ export const DECRYPTION_CLASSES = {}; /** * 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 */ export class EncryptionAlgorithm { constructor(params) { this._userId = params.userId; this._deviceId = params.deviceId; this._crypto = params.crypto; this._olmDevice = params.olmDevice; this._baseApis = params.baseApis; this._roomId = params.roomId; } /** * Perform any background tasks that can be done before a message is ready to * send, in order to speed up sending of the message. * * @param {module:models/room} room the room the event is in */ prepareToEncrypt(room) { } /** * 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 {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) { } } /** * 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. */ export class DecryptionAlgorithm { constructor(params) { this._userId = params.userId; this._crypto = params.crypto; this._olmDevice = params.olmDevice; this._baseApis = params.baseApis; this._roomId = params.roomId; } /** * 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"); } /** * Retry decrypting all the events from a sender that haven't been * decrypted yet. * * @param {string} senderKey the sender's key */ async retryDecryptionFromSender(senderKey) { // ignore by default } } /** * 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 */ export class DecryptionError extends Error { constructor(code, msg, details) { super(msg); this.code = code; this.name = 'DecryptionError'; this.detailedString = _detailedStringForDecryptionError(this, details); } } 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[algorithm] = encryptor; DECRYPTION_CLASSES[algorithm] = decryptor; } matrix-js-sdk-9.11.0/src/crypto/algorithms/index.js000066400000000000000000000013041403504662500222270ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module crypto/algorithms */ import "./olm"; import "./megolm"; export * from "./base"; matrix-js-sdk-9.11.0/src/crypto/algorithms/megolm.js000066400000000000000000001752641403504662500224210ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2018 New Vector Ltd Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Defines m.olm encryption/decryption * * @module crypto/algorithms/megolm */ import {logger} from '../../logger'; import * as utils from "../../utils"; import {polyfillSuper} from "../../utils"; import * as olmlib from "../olmlib"; import { DecryptionAlgorithm, DecryptionError, EncryptionAlgorithm, registerAlgorithm, UnknownDeviceError, } from "./base"; import {WITHHELD_MESSAGES} from '../OlmDevice'; // determine whether the key can be shared with invitees function isRoomSharedHistory(room) { const visibilityEvent = room.currentState && room.currentState.getStateEvents("m.room.history_visibility", ""); // NOTE: if the room visibility is unset, it would normally default to // "world_readable". // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) // But we will be paranoid here, and treat it as a situation where the room // is not shared-history const visibility = visibilityEvent && visibilityEvent.getContent() && visibilityEvent.getContent().history_visibility; return ["world_readable", "shared"].includes(visibility); } /** * @private * @constructor * * @param {string} sessionId * @param {boolean} sharedHistory whether the session can be freely shared with * other group members, according to the room history visibility settings * * @property {string} sessionId * @property {Number} useCount number of times this session has been used * @property {Number} creationTime when the session was created (ms since the epoch) * * @property {object} sharedWithDevices * devices with which we have shared the session key * userId -> {deviceId -> msgindex} */ function OutboundSessionInfo(sessionId, sharedHistory = false) { this.sessionId = sessionId; this.useCount = 0; this.creationTime = new Date().getTime(); this.sharedWithDevices = {}; this.blockedDevicesNotified = {}; this.sharedHistory = sharedHistory; } /** * Check if it's time to rotate the session * * @param {Number} rotationPeriodMsgs * @param {Number} rotationPeriodMs * @return {Boolean} */ OutboundSessionInfo.prototype.needsRotation = function( rotationPeriodMsgs, rotationPeriodMs, ) { const sessionLifetime = new Date().getTime() - this.creationTime; if (this.useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs ) { logger.log( "Rotating megolm session after " + this.useCount + " messages, " + sessionLifetime + "ms", ); return true; } return false; }; OutboundSessionInfo.prototype.markSharedWithDevice = function( userId, deviceId, chainIndex, ) { if (!this.sharedWithDevices[userId]) { this.sharedWithDevices[userId] = {}; } this.sharedWithDevices[userId][deviceId] = chainIndex; }; OutboundSessionInfo.prototype.markNotifiedBlockedDevice = function( userId, deviceId, ) { if (!this.blockedDevicesNotified[userId]) { this.blockedDevicesNotified[userId] = {}; } this.blockedDevicesNotified[userId][deviceId] = true; }; /** * Determine if this session has been shared with devices which it shouldn't * have been. * * @param {Object} devicesInRoom userId -> {deviceId -> object} * devices we should shared the session with. * * @return {Boolean} true if we have shared the session with devices which aren't * in devicesInRoom. */ OutboundSessionInfo.prototype.sharedWithTooManyDevices = function( devicesInRoom, ) { for (const userId in this.sharedWithDevices) { if (!this.sharedWithDevices.hasOwnProperty(userId)) { continue; } if (!devicesInRoom.hasOwnProperty(userId)) { logger.log("Starting new megolm session because we shared with " + userId); return true; } for (const deviceId in this.sharedWithDevices[userId]) { if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) { continue; } if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { logger.log( "Starting new megolm session because we shared with " + userId + ":" + deviceId, ); return true; } } } }; /** * Megolm encryption implementation * * @constructor * @extends {module:crypto/algorithms/EncryptionAlgorithm} * * @param {object} params parameters, as per * {@link module:crypto/algorithms/EncryptionAlgorithm} */ function MegolmEncryption(params) { polyfillSuper(this, EncryptionAlgorithm, params); // the most recent attempt to set up a session. This is used to serialise // the session setups, so that we have a race-free view of which session we // are using, and which devices we have shared the keys with. It resolves // with an OutboundSessionInfo (or undefined, for the first message in the // room). this._setupPromise = Promise.resolve(); // Map of outbound sessions by sessions ID. Used if we need a particular // session (the session we're currently using to send is always obtained // using _setupPromise). this._outboundSessions = {}; // default rotation periods this._sessionRotationPeriodMsgs = 100; this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000; if (params.config.rotation_period_ms !== undefined) { this._sessionRotationPeriodMs = params.config.rotation_period_ms; } if (params.config.rotation_period_msgs !== undefined) { this._sessionRotationPeriodMsgs = params.config.rotation_period_msgs; } } utils.inherits(MegolmEncryption, EncryptionAlgorithm); /** * @private * * @param {module:models/room} room * @param {Object} devicesInRoom The devices in this room, indexed by user ID * @param {Object} blocked The devices that are blocked, indexed by user ID * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm * session creation * * @return {Promise} Promise which resolves to the * OutboundSessionInfo when setup is complete. */ MegolmEncryption.prototype._ensureOutboundSession = async function( room, devicesInRoom, blocked, singleOlmCreationPhase, ) { let session; // takes the previous OutboundSessionInfo, and considers whether to create // a new one. Also shares the key with any (new) devices in the room. // Updates `session` to hold the final OutboundSessionInfo. // // returns a promise which resolves once the keyshare is successful. const prepareSession = async (oldSession) => { session = oldSession; const sharedHistory = isRoomSharedHistory(room); // history visibility changed if (session && sharedHistory !== session.sharedHistory) { session = null; } // need to make a brand new session? if (session && session.needsRotation(this._sessionRotationPeriodMsgs, this._sessionRotationPeriodMs) ) { logger.log("Starting new megolm session because we need to rotate."); session = null; } // determine if we have shared with anyone we shouldn't have if (session && session.sharedWithTooManyDevices(devicesInRoom)) { session = null; } if (!session) { logger.log(`Starting new megolm session for room ${this._roomId}`); session = await this._prepareNewSession(sharedHistory); logger.log(`Started new megolm session ${session.sessionId} ` + `for room ${this._roomId}`); this._outboundSessions[session.sessionId] = session; } // now check if we need to share with any devices const shareMap = {}; for (const [userId, userDevices] of Object.entries(devicesInRoom)) { for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { const key = deviceInfo.getIdentityKey(); if (key == this._olmDevice.deviceCurve25519Key) { // don't bother sending to ourself continue; } if ( !session.sharedWithDevices[userId] || session.sharedWithDevices[userId][deviceId] === undefined ) { shareMap[userId] = shareMap[userId] || []; shareMap[userId].push(deviceInfo); } } } const key = this._olmDevice.getOutboundGroupSessionKey(session.sessionId); const payload = { type: "m.room_key", content: { "algorithm": olmlib.MEGOLM_ALGORITHM, "room_id": this._roomId, "session_id": session.sessionId, "session_key": key.key, "chain_index": key.chain_index, "org.matrix.msc3061.shared_history": sharedHistory, }, }; const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( this._olmDevice, this._baseApis, shareMap, ); await Promise.all([ (async () => { // share keys with devices that we already have a session for logger.debug(`Sharing keys with existing Olm sessions in ${this._roomId}`); await this._shareKeyWithOlmSessions( session, key, payload, olmSessions, ); logger.debug(`Shared keys with existing Olm sessions in ${this._roomId}`); })(), (async () => { logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this._roomId}`); const errorDevices = []; // meanwhile, establish olm sessions for devices that we don't // already have a session for, and share keys with them. If // we're doing two phases of olm session creation, use a // shorter timeout when fetching one-time keys for the first // phase. const start = Date.now(); const failedServers = []; await this._shareKeyWithDevices( session, key, payload, devicesWithoutSession, errorDevices, singleOlmCreationPhase ? 10000 : 2000, failedServers, ); logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this._roomId}`); if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { // perform the second phase of olm session creation if requested, // and if the first phase didn't take too long (async () => { // Retry sending keys to devices that we were unable to establish // an olm session for. This time, we use a longer timeout, but we // do this in the background and don't block anything else while we // do this. We only need to retry users from servers that didn't // respond the first time. const retryDevices = {}; const failedServerMap = new Set; for (const server of failedServers) { failedServerMap.add(server); } const failedDevices = []; for (const {userId, deviceInfo} of errorDevices) { const userHS = userId.slice(userId.indexOf(":") + 1); if (failedServerMap.has(userHS)) { retryDevices[userId] = retryDevices[userId] || []; retryDevices[userId].push(deviceInfo); } else { // if we aren't going to retry, then handle it // as a failed device failedDevices.push({userId, deviceInfo}); } } logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this._roomId}`); await this._shareKeyWithDevices( session, key, payload, retryDevices, failedDevices, 30000, ); logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this._roomId}`); await this._notifyFailedOlmDevices(session, key, failedDevices); })(); } else { await this._notifyFailedOlmDevices(session, key, errorDevices); } logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this._roomId}`); })(), (async () => { logger.debug(`Notifying blocked devices in ${this._roomId}`); // also, notify blocked devices that they're blocked const blockedMap = {}; let blockedCount = 0; for (const [userId, userBlockedDevices] of Object.entries(blocked)) { for (const [deviceId, device] of Object.entries(userBlockedDevices)) { if ( !session.blockedDevicesNotified[userId] || session.blockedDevicesNotified[userId][deviceId] === undefined ) { blockedMap[userId] = blockedMap[userId] || {}; blockedMap[userId][deviceId] = { device }; blockedCount++; } } } await this._notifyBlockedDevices(session, blockedMap); logger.debug(`Notified ${blockedCount} blocked devices in ${this._roomId}`); })(), ]); }; // helper which returns the session prepared by prepareSession function returnSession() { return session; } // first wait for the previous share to complete const prom = this._setupPromise.then(prepareSession); // Ensure any failures are logged for debugging prom.catch(e => { logger.error(`Failed to ensure outbound session in ${this._roomId}`, e); }); // _setupPromise resolves to `session` whether or not the share succeeds this._setupPromise = prom.then(returnSession, returnSession); // but we return a promise which only resolves if the share was successful. return prom.then(returnSession); }; /** * @private * * @param {boolean} sharedHistory * * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session */ MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) { const sessionId = this._olmDevice.createOutboundGroupSession(); const key = this._olmDevice.getOutboundGroupSessionKey(sessionId); await this._olmDevice.addInboundGroupSession( this._roomId, this._olmDevice.deviceCurve25519Key, [], sessionId, key.key, {ed25519: this._olmDevice.deviceEd25519Key}, false, {sharedHistory: sharedHistory}, ); // don't wait for it to complete this._crypto.backupGroupSession( this._roomId, this._olmDevice.deviceCurve25519Key, [], sessionId, key.key, ); return new OutboundSessionInfo(sessionId, sharedHistory); }; /** * Determines what devices in devicesByUser don't have an olm session as given * in devicemap. * * @private * * @param {object} devicemap the devices that have olm sessions, as returned by * olmlib.ensureOlmSessionsForDevices. * @param {object} devicesByUser a map of user IDs to array of deviceInfo * @param {array} [noOlmDevices] an array to fill with devices that don't have * olm sessions * * @return {array} an array of devices that don't have olm sessions. If * noOlmDevices is specified, then noOlmDevices will be returned. */ MegolmEncryption.prototype._getDevicesWithoutSessions = function( devicemap, devicesByUser, noOlmDevices, ) { noOlmDevices = noOlmDevices || []; for (const [userId, devicesToShareWith] of Object.entries(devicesByUser)) { const sessionResults = devicemap[userId]; for (const deviceInfo of devicesToShareWith) { const deviceId = deviceInfo.deviceId; const sessionResult = sessionResults[deviceId]; if (!sessionResult.sessionId) { // no session with this device, probably because there // were no one-time keys. noOlmDevices.push({userId, deviceInfo}); delete sessionResults[deviceId]; // ensureOlmSessionsForUsers has already done the logging, // so just skip it. continue; } } } return noOlmDevices; }; /** * Splits the user device map into multiple chunks to reduce the number of * devices we encrypt to per API call. * * @private * * @param {object} devicesByUser map from userid to list of devices * * @return {array>} the blocked devices, split into chunks */ MegolmEncryption.prototype._splitDevices = function(devicesByUser) { const maxDevicesPerRequest = 20; // use an array where the slices of a content map gets stored let currentSlice = []; const mapSlices = [currentSlice]; for (const [userId, userDevices] of Object.entries(devicesByUser)) { for (const deviceInfo of Object.values(userDevices)) { currentSlice.push({ userId: userId, deviceInfo: deviceInfo.device, }); } // We do this in the per-user loop as we prefer that all messages to the // same user end up in the same API call to make it easier for the // server (e.g. only have to send one EDU if a remote user, etc). This // does mean that if a user has many devices we may go over the desired // limit, but its not a hard limit so that is fine. if (currentSlice.length > maxDevicesPerRequest) { // the current slice is filled up. Start inserting into the next slice currentSlice = []; mapSlices.push(currentSlice); } } if (currentSlice.length === 0) { mapSlices.pop(); } return mapSlices; }; /** * @private * * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * * @param {number} chainIndex current chain index * * @param {object} userDeviceMap * mapping from userId to deviceInfo * * @param {object} payload fields to include in the encrypted payload * * @return {Promise} Promise which resolves once the key sharing * for the given userDeviceMap is generated and has been sent. */ MegolmEncryption.prototype._encryptAndSendKeysToDevices = function( session, chainIndex, userDeviceMap, payload, ) { const contentMap = {}; const promises = []; for (let i = 0; i < userDeviceMap.length; i++) { const encryptedContent = { algorithm: olmlib.OLM_ALGORITHM, sender_key: this._olmDevice.deviceCurve25519Key, ciphertext: {}, }; const val = userDeviceMap[i]; const userId = val.userId; const deviceInfo = val.deviceInfo; const deviceId = deviceInfo.deviceId; if (!contentMap[userId]) { contentMap[userId] = {}; } contentMap[userId][deviceId] = encryptedContent; promises.push( olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this._userId, this._deviceId, this._olmDevice, userId, deviceInfo, payload, ), ); } return Promise.all(promises).then(() => { // prune out any devices that encryptMessageForDevice could not encrypt for, // in which case it will have just not added anything to the ciphertext object. // There's no point sending messages to devices if we couldn't encrypt to them, // since that's effectively a blank message. for (const userId of Object.keys(contentMap)) { for (const deviceId of Object.keys(contentMap[userId])) { if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { logger.log( "No ciphertext for device " + userId + ":" + deviceId + ": pruning", ); delete contentMap[userId][deviceId]; } } // No devices left for that user? Strip that too. if (Object.keys(contentMap[userId]).length === 0) { logger.log("Pruned all devices for user " + userId); delete contentMap[userId]; } } // Is there anything left? if (Object.keys(contentMap).length === 0) { logger.log("No users left to send to: aborting"); return; } return this._baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => { // store that we successfully uploaded the keys of the current slice for (const userId of Object.keys(contentMap)) { for (const deviceId of Object.keys(contentMap[userId])) { session.markSharedWithDevice( userId, deviceId, chainIndex, ); } } }); }); }; /** * @private * * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * * @param {array} userDeviceMap list of blocked devices to notify * * @param {object} payload fields to include in the notification payload * * @return {Promise} Promise which resolves once the notifications * for the given userDeviceMap is generated and has been sent. */ MegolmEncryption.prototype._sendBlockedNotificationsToDevices = async function( session, userDeviceMap, payload, ) { const contentMap = {}; for (const val of userDeviceMap) { const userId = val.userId; const blockedInfo = val.deviceInfo; const deviceInfo = blockedInfo.deviceInfo; const deviceId = deviceInfo.deviceId; const message = Object.assign({}, payload); message.code = blockedInfo.code; message.reason = blockedInfo.reason; if (message.code === "m.no_olm") { delete message.room_id; delete message.session_id; } if (!contentMap[userId]) { contentMap[userId] = {}; } contentMap[userId][deviceId] = message; } await this._baseApis.sendToDevice("org.matrix.room_key.withheld", contentMap); // store that we successfully uploaded the keys of the current slice for (const userId of Object.keys(contentMap)) { for (const deviceId of Object.keys(contentMap[userId])) { session.markNotifiedBlockedDevice(userId, deviceId); } } }; /** * Re-shares a megolm session key with devices if the key has already been * sent to them. * * @param {string} senderKey The key of the originating device for the session * @param {string} sessionId ID of the outbound session to share * @param {string} userId ID of the user who owns the target device * @param {module:crypto/deviceinfo} device The target device */ MegolmEncryption.prototype.reshareKeyWithDevice = async function( senderKey, sessionId, userId, device, ) { const obSessionInfo = this._outboundSessions[sessionId]; if (!obSessionInfo) { logger.debug(`megolm session ${sessionId} not found: not re-sharing keys`); return; } // The chain index of the key we previously sent this device if (obSessionInfo.sharedWithDevices[userId] === undefined) { logger.debug(`megolm session ${sessionId} never shared with user ${userId}`); return; } const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; if (sentChainIndex === undefined) { logger.debug( "megolm session ID " + sessionId + " never shared with device " + userId + ":" + device.deviceId, ); return; } // get the key from the inbound session: the outbound one will already // have been ratcheted to the next chain index. const key = await this._olmDevice.getInboundGroupSessionKey( this._roomId, senderKey, sessionId, sentChainIndex, ); if (!key) { logger.warn( `No inbound session key found for megolm ${sessionId}: not re-sharing keys`, ); return; } await olmlib.ensureOlmSessionsForDevices( this._olmDevice, this._baseApis, { [userId]: [device], }, ); const payload = { type: "m.forwarded_room_key", content: { "algorithm": olmlib.MEGOLM_ALGORITHM, "room_id": this._roomId, "session_id": sessionId, "session_key": key.key, "chain_index": key.chain_index, "sender_key": senderKey, "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, "org.matrix.msc3061.shared_history": key.shared_history || false, }, }; const encryptedContent = { algorithm: olmlib.OLM_ALGORITHM, sender_key: this._olmDevice.deviceCurve25519Key, ciphertext: {}, }; await olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this._userId, this._deviceId, this._olmDevice, userId, device, payload, ); await this._baseApis.sendToDevice("m.room.encrypted", { [userId]: { [device.deviceId]: encryptedContent, }, }); logger.debug(`Re-shared key for megolm session ${sessionId} ` + `with ${userId}:${device.deviceId}`); }; /** * @private * * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * * @param {object} key the session key as returned by * OlmDevice.getOutboundGroupSessionKey * * @param {object} payload the base to-device message payload for sharing keys * * @param {object} devicesByUser * map from userid to list of devices * * @param {array} errorDevices * array that will be populated with the devices that we can't get an * olm session for * * @param {Number} [otkTimeout] The timeout in milliseconds when requesting * one-time keys for establishing new olm sessions. * * @param {Array} [failedServers] An array to fill with remote servers that * failed to respond to one-time-key requests. */ MegolmEncryption.prototype._shareKeyWithDevices = async function( session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers, ) { logger.debug(`Ensuring Olm sessions for devices in ${this._roomId}`); const devicemap = await olmlib.ensureOlmSessionsForDevices( this._olmDevice, this._baseApis, devicesByUser, otkTimeout, failedServers, logger.withPrefix(`[${this._roomId}]`), ); logger.debug(`Ensured Olm sessions for devices in ${this._roomId}`); this._getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); logger.debug(`Sharing keys with Olm sessions in ${this._roomId}`); await this._shareKeyWithOlmSessions(session, key, payload, devicemap); logger.debug(`Shared keys with Olm sessions in ${this._roomId}`); }; MegolmEncryption.prototype._shareKeyWithOlmSessions = async function( session, key, payload, devicemap, ) { const userDeviceMaps = this._splitDevices(devicemap); for (let i = 0; i < userDeviceMaps.length; i++) { const taskDetail = `megolm keys for ${session.sessionId} ` + `in ${this._roomId} (slice ${i + 1}/${userDeviceMaps.length})`; try { logger.debug(`Sharing ${taskDetail}`); await this._encryptAndSendKeysToDevices( session, key.chain_index, userDeviceMaps[i], payload, ); logger.debug(`Shared ${taskDetail}`); } catch (e) { logger.error(`Failed to share ${taskDetail}`); throw e; } } }; /** * Notify devices that we weren't able to create olm sessions. * * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * * @param {object} key * * @param {Array} failedDevices the devices that we were unable to * create olm sessions for, as returned by _shareKeyWithDevices */ MegolmEncryption.prototype._notifyFailedOlmDevices = async function( session, key, failedDevices, ) { logger.debug( `Notifying ${failedDevices.length} devices we failed to ` + `create Olm sessions in ${this._roomId}`, ); // mark the devices that failed as "handled" because we don't want to try // to claim a one-time-key for dead devices on every message. for (const {userId, deviceInfo} of failedDevices) { const deviceId = deviceInfo.deviceId; session.markSharedWithDevice( userId, deviceId, key.chain_index, ); } const filteredFailedDevices = await this._olmDevice.filterOutNotifiedErrorDevices( failedDevices, ); logger.debug( `Filtered down to ${filteredFailedDevices.length} error devices ` + `in ${this._roomId}`, ); const blockedMap = {}; for (const {userId, deviceInfo} of filteredFailedDevices) { blockedMap[userId] = blockedMap[userId] || {}; // we use a similar format to what // olmlib.ensureOlmSessionsForDevices returns, so that // we can use the same function to split blockedMap[userId][deviceInfo.deviceId] = { device: { code: "m.no_olm", reason: WITHHELD_MESSAGES["m.no_olm"], deviceInfo, }, }; } // send the notifications await this._notifyBlockedDevices(session, blockedMap); logger.debug( `Notified ${filteredFailedDevices.length} devices we failed to ` + `create Olm sessions in ${this._roomId}`, ); }; /** * Notify blocked devices that they have been blocked. * * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * * @param {object} devicesByUser * map from userid to device ID to blocked data */ MegolmEncryption.prototype._notifyBlockedDevices = async function( session, devicesByUser, ) { const payload = { room_id: this._roomId, session_id: session.sessionId, algorithm: olmlib.MEGOLM_ALGORITHM, sender_key: this._olmDevice.deviceCurve25519Key, }; const userDeviceMaps = this._splitDevices(devicesByUser); for (let i = 0; i < userDeviceMaps.length; i++) { try { await this._sendBlockedNotificationsToDevices( session, userDeviceMaps[i], payload, ); logger.log(`Completed blacklist notification for ${session.sessionId} ` + `in ${this._roomId} (slice ${i + 1}/${userDeviceMaps.length})`); } catch (e) { logger.log(`blacklist notification for ${session.sessionId} in ` + `${this._roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`); throw e; } } }; /** * Perform any background tasks that can be done before a message is ready to * send, in order to speed up sending of the message. * * @param {module:models/room} room the room the event is in */ MegolmEncryption.prototype.prepareToEncrypt = function(room) { if (this.encryptionPreparation) { // We're already preparing something, so don't do anything else. // FIXME: check if we need to restart // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; logger.debug( `Already started preparing to encrypt for ${this._roomId} ` + `${elapsedTime} ms ago, skipping`, ); return; } logger.debug(`Preparing to encrypt events for ${this._roomId}`); this.encryptionPreparationMetadata = { startTime: Date.now(), }; this.encryptionPreparation = (async () => { try { logger.debug(`Getting devices in ${this._roomId}`); const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); if (this._crypto.getGlobalErrorOnUnknownDevices()) { // Drop unknown devices for now. When the message gets sent, we'll // throw an error, but we'll still be prepared to send to the known // devices. this._removeUnknownDevices(devicesInRoom); } logger.debug(`Ensuring outbound session in ${this._roomId}`); await this._ensureOutboundSession(room, devicesInRoom, blocked, true); logger.debug(`Ready to encrypt events for ${this._roomId}`); } catch (e) { logger.error(`Failed to prepare to encrypt events for ${this._roomId}`, e); } finally { delete this.encryptionPreparationMetadata; delete this.encryptionPreparation; } })(); }; /** * @inheritdoc * * @param {module:models/room} room * @param {string} eventType * @param {object} content plaintext event content * * @return {Promise} Promise which resolves to the new event body */ MegolmEncryption.prototype.encryptMessage = async function(room, eventType, content) { logger.log(`Starting to encrypt event for ${this._roomId}`); if (this.encryptionPreparation) { // If we started sending keys, wait for it to be done. // FIXME: check if we need to cancel // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) try { await this.encryptionPreparation; } catch (e) { // ignore any errors -- if the preparation failed, we'll just // restart everything here } } const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); // check if any of these devices are not yet known to the user. // if so, warn the user so they can verify or ignore. if (this._crypto.getGlobalErrorOnUnknownDevices()) { this._checkForUnknownDevices(devicesInRoom); } const session = await this._ensureOutboundSession(room, devicesInRoom, blocked); const payloadJson = { room_id: this._roomId, type: eventType, content: content, }; const ciphertext = this._olmDevice.encryptGroupMessage( session.sessionId, JSON.stringify(payloadJson), ); const encryptedContent = { algorithm: olmlib.MEGOLM_ALGORITHM, sender_key: this._olmDevice.deviceCurve25519Key, ciphertext: ciphertext, session_id: session.sessionId, // Include our device ID so that recipients can send us a // m.new_device message if they don't have our session key. // XXX: Do we still need this now that m.new_device messages // no longer exist since #483? device_id: this._deviceId, }; session.useCount++; return encryptedContent; }; /** * Forces the current outbound group session to be discarded such * that another one will be created next time an event is sent. * * This should not normally be necessary. */ MegolmEncryption.prototype.forceDiscardSession = function() { this._setupPromise = this._setupPromise.then(() => null); }; /** * Checks the devices we're about to send to and see if any are entirely * unknown to the user. If so, warn the user, and mark them as known to * give the user a chance to go verify them before re-sending this message. * * @param {Object} devicesInRoom userId -> {deviceId -> object} * devices we should shared the session with. */ MegolmEncryption.prototype._checkForUnknownDevices = function(devicesInRoom) { const unknownDevices = {}; Object.keys(devicesInRoom).forEach((userId)=>{ Object.keys(devicesInRoom[userId]).forEach((deviceId)=>{ const device = devicesInRoom[userId][deviceId]; if (device.isUnverified() && !device.isKnown()) { if (!unknownDevices[userId]) { unknownDevices[userId] = {}; } unknownDevices[userId][deviceId] = device; } }); }); if (Object.keys(unknownDevices).length) { // it'd be kind to pass unknownDevices up to the user in this error throw new UnknownDeviceError( "This room contains unknown devices which have not been verified. " + "We strongly recommend you verify them before continuing.", unknownDevices); } }; /** * Remove unknown devices from a set of devices. The devicesInRoom parameter * will be modified. * * @param {Object} devicesInRoom userId -> {deviceId -> object} * devices we should shared the session with. */ MegolmEncryption.prototype._removeUnknownDevices = function(devicesInRoom) { for (const [userId, userDevices] of Object.entries(devicesInRoom)) { for (const [deviceId, device] of Object.entries(userDevices)) { if (device.isUnverified() && !device.isKnown()) { delete userDevices[deviceId]; } } if (Object.keys(userDevices).length === 0) { delete devicesInRoom[userId]; } } }; /** * Get the list of unblocked devices for all users in the room * * @param {module:models/room} room * * @return {Promise} Promise which resolves to an array whose * first element is a map from userId to deviceId to deviceInfo indicating * the devices that messages should be encrypted to, and whose second * element is a map from userId to deviceId to data indicating the devices * that are in the room but that have been blocked */ MegolmEncryption.prototype._getDevicesInRoom = async function(room) { const members = await room.getEncryptionTargetMembers(); const roomMembers = 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/element-web/issues/2305 for details. const devices = await this._crypto.downloadKeys(roomMembers, false); const blocked = {}; // remove any blocked devices for (const userId in devices) { if (!devices.hasOwnProperty(userId)) { continue; } const userDevices = devices[userId]; for (const deviceId in userDevices) { if (!userDevices.hasOwnProperty(deviceId)) { continue; } const deviceTrust = this._crypto.checkDeviceTrust(userId, deviceId); if (userDevices[deviceId].isBlocked() || (!deviceTrust.isVerified() && isBlacklisting) ) { if (!blocked[userId]) { blocked[userId] = {}; } const blockedInfo = userDevices[deviceId].isBlocked() ? { code: "m.blacklisted", reason: WITHHELD_MESSAGES["m.blacklisted"], } : { code: "m.unverified", reason: WITHHELD_MESSAGES["m.unverified"], }; blockedInfo.deviceInfo = userDevices[deviceId]; blocked[userId][deviceId] = blockedInfo; delete userDevices[deviceId]; } } } return [devices, blocked]; }; /** * Megolm decryption implementation * * @constructor * @extends {module:crypto/algorithms/DecryptionAlgorithm} * * @param {object} params parameters, as per * {@link module:crypto/algorithms/DecryptionAlgorithm} */ function MegolmDecryption(params) { polyfillSuper(this, DecryptionAlgorithm, params); // events which we couldn't decrypt due to unknown sessions / indexes: map from // senderKey|sessionId to Set of MatrixEvents this._pendingEvents = {}; // this gets stubbed out by the unit tests. this.olmlib = olmlib; } utils.inherits(MegolmDecryption, DecryptionAlgorithm); const PROBLEM_DESCRIPTIONS = { no_olm: "The sender was unable to establish a secure channel.", unknown: "The secure channel with the sender was corrupted.", }; /** * @inheritdoc * * @param {MatrixEvent} event * * returns a promise which resolves to a * {@link module:crypto~EventDecryptionResult} once we have finished * decrypting, or rejects with an `algorithms.DecryptionError` if there is a * problem decrypting the event. */ MegolmDecryption.prototype.decryptEvent = async function(event) { const content = event.getWireContent(); if (!content.sender_key || !content.session_id || !content.ciphertext ) { throw new DecryptionError( "MEGOLM_MISSING_FIELDS", "Missing fields in input", ); } // we add the event to the pending list *before* we start decryption. // // then, if the key turns up while decryption is in progress (and // decryption fails), we will schedule a retry. // (fixes https://github.com/vector-im/element-web/issues/5001) this._addEventToPendingList(event); let res; try { res = await this._olmDevice.decryptGroupMessage( event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, event.getId(), event.getTs(), ); } catch (e) { if (e.name === "DecryptionError") { // re-throw decryption errors as-is throw e; } let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { this._requestKeysForEvent(event); errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX'; } throw new DecryptionError( errorCode, e ? e.toString() : "Unknown Error: Error is undefined", { session: content.sender_key + '|' + content.session_id, }, ); } if (res === null) { // We've got a message for a session we don't have. // // (XXX: We might actually have received this key since we started // decrypting, in which case we'll have scheduled a retry, and this // request will be redundant. We could probably check to see if the // event is still in the pending list; if not, a retry will have been // scheduled, so we needn't send out the request here.) this._requestKeysForEvent(event); // See if there was a problem with the olm session at the time the // event was sent. Use a fuzz factor of 2 minutes. const problem = await this._olmDevice.sessionMayHaveProblems( content.sender_key, event.getTs() - 120000, ); if (problem) { let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] || PROBLEM_DESCRIPTIONS.unknown; if (problem.fixed) { problemDescription += " Trying to create a new secure channel and re-requesting the keys."; } throw new DecryptionError( "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, { session: content.sender_key + '|' + content.session_id, }, ); } throw new DecryptionError( "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", "The sender's device has not sent us the keys for this message.", { session: content.sender_key + '|' + content.session_id, }, ); } // success. We can remove the event from the pending list, if that hasn't // already happened. this._removeEventFromPendingList(event); const payload = JSON.parse(res.result); // belt-and-braces check that the room id matches that indicated by the HS // (this is somewhat redundant, since the megolm session is scoped to the // room, so neither the sender nor a MITM can lie about the room_id). if (payload.room_id !== event.getRoomId()) { throw new DecryptionError( "MEGOLM_BAD_ROOM", "Message intended for room " + payload.room_id, ); } return { clearEvent: payload, senderCurve25519Key: res.senderKey, claimedEd25519Key: res.keysClaimed.ed25519, forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, untrusted: res.untrusted, }; }; MegolmDecryption.prototype._requestKeysForEvent = function(event) { const wireContent = event.getWireContent(); const recipients = event.getKeyRequestRecipients(this._userId); this._crypto.requestRoomKey({ room_id: event.getRoomId(), algorithm: wireContent.algorithm, sender_key: wireContent.sender_key, session_id: wireContent.session_id, }, recipients); }; /** * Add an event to the list of those awaiting their session keys. * * @private * * @param {module:models/event.MatrixEvent} event */ MegolmDecryption.prototype._addEventToPendingList = function(event) { const content = event.getWireContent(); const senderKey = content.sender_key; const sessionId = content.session_id; if (!this._pendingEvents[senderKey]) { this._pendingEvents[senderKey] = new Map(); } const senderPendingEvents = this._pendingEvents[senderKey]; if (!senderPendingEvents.has(sessionId)) { senderPendingEvents.set(sessionId, new Set()); } senderPendingEvents.get(sessionId).add(event); }; /** * Remove an event from the list of those awaiting their session keys. * * @private * * @param {module:models/event.MatrixEvent} event */ MegolmDecryption.prototype._removeEventFromPendingList = function(event) { const content = event.getWireContent(); const senderKey = content.sender_key; const sessionId = content.session_id; const senderPendingEvents = this._pendingEvents[senderKey]; const pendingEvents = senderPendingEvents && senderPendingEvents.get(sessionId); if (!pendingEvents) { return; } pendingEvents.delete(event); if (pendingEvents.size === 0) { senderPendingEvents.delete(senderKey); } if (senderPendingEvents.size === 0) { delete this._pendingEvents[senderKey]; } }; /** * @inheritdoc * * @param {module:models/event.MatrixEvent} event key event */ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { const content = event.getContent(); const sessionId = content.session_id; let senderKey = event.getSenderKey(); let forwardingKeyChain = []; let exportFormat = false; let keysClaimed; if (!content.room_id || !sessionId || !content.session_key ) { logger.error("key event is missing fields"); return; } if (!senderKey) { logger.error("key event has no sender key (not encrypted?)"); return; } if (event.getType() == "m.forwarded_room_key") { exportFormat = true; forwardingKeyChain = content.forwarding_curve25519_key_chain; if (!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(); } const extraSessionData = {}; if (content["org.matrix.msc3061.shared_history"]) { extraSessionData.sharedHistory = true; } return this._olmDevice.addInboundGroupSession( content.room_id, senderKey, forwardingKeyChain, sessionId, content.session_key, keysClaimed, exportFormat, extraSessionData, ).then(() => { // have another go at decrypting events sent with this session. this._retryDecryption(senderKey, sessionId) .then((success) => { // cancel any outstanding room key requests for this session. // Only do this if we managed to decrypt every message in the // session, because if we didn't, we leave the other key // requests in the hopes that someone sends us a key that // includes an earlier index. if (success) { this._crypto.cancelRoomKeyRequest({ algorithm: content.algorithm, room_id: content.room_id, session_id: content.session_id, sender_key: senderKey, }); } }); }).then(() => { // don't wait for the keys to be backed up for the server this._crypto.backupGroupSession( content.room_id, senderKey, forwardingKeyChain, content.session_id, content.session_key, keysClaimed, exportFormat, ); }).catch((e) => { logger.error(`Error handling m.room_key_event: ${e}`); }); }; /** * @inheritdoc * * @param {module:models/event.MatrixEvent} event key event */ MegolmDecryption.prototype.onRoomKeyWithheldEvent = async function(event) { const content = event.getContent(); const senderKey = content.sender_key; if (content.code === "m.no_olm") { const sender = event.getSender(); logger.warn( `${sender}:${senderKey} was unable to establish an olm session with us`, ); // if the sender says that they haven't been able to establish an olm // session, let's proactively establish one // Note: after we record that the olm session has had a problem, we // trigger retrying decryption for all the messages from the sender's // key, so that we can update the error message to indicate the olm // session problem. if (await this._olmDevice.getSessionIdForDevice(senderKey)) { // a session has already been established, so we don't need to // create a new one. logger.debug("New session already created. Not creating a new one."); await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); this.retryDecryptionFromSender(senderKey); return; } let device = this._crypto._deviceList.getDeviceByIdentityKey( content.algorithm, senderKey, ); if (!device) { // if we don't know about the device, fetch the user's devices again // and retry before giving up await this._crypto.downloadKeys([sender], false); device = this._crypto._deviceList.getDeviceByIdentityKey( content.algorithm, senderKey, ); if (!device) { logger.info( "Couldn't find device for identity key " + senderKey + ": not establishing session", ); await this._olmDevice.recordSessionProblem(senderKey, "no_olm", false); this.retryDecryptionFromSender(senderKey); return; } } await olmlib.ensureOlmSessionsForDevices( this._olmDevice, this._baseApis, {[sender]: [device]}, false, ); const encryptedContent = { algorithm: olmlib.OLM_ALGORITHM, sender_key: this._olmDevice.deviceCurve25519Key, ciphertext: {}, }; await olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this._userId, this._deviceId, this._olmDevice, sender, device, {type: "m.dummy"}, ); await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); this.retryDecryptionFromSender(senderKey); await this._baseApis.sendToDevice("m.room.encrypted", { [sender]: { [device.deviceId]: encryptedContent, }, }); } else { await this._olmDevice.addInboundGroupSessionWithheld( content.room_id, senderKey, content.session_id, content.code, content.reason, ); } }; /** * @inheritdoc */ MegolmDecryption.prototype.hasKeysForKeyRequest = function(keyRequest) { const body = keyRequest.requestBody; return this._olmDevice.hasInboundSessionKeys( body.room_id, body.sender_key, body.session_id, // TODO: ratchet index ); }; /** * @inheritdoc */ MegolmDecryption.prototype.shareKeysWithDevice = function(keyRequest) { const userId = keyRequest.userId; const deviceId = keyRequest.deviceId; const deviceInfo = this._crypto.getStoredDevice(userId, deviceId); const body = keyRequest.requestBody; this.olmlib.ensureOlmSessionsForDevices( this._olmDevice, this._baseApis, { [userId]: [deviceInfo], }, ).then((devicemap) => { const olmSessionResult = devicemap[userId][deviceId]; if (!olmSessionResult.sessionId) { // no session with this device, probably because there // were no one-time keys. // // ensureOlmSessionsForUsers has already done the logging, // so just skip it. return null; } logger.log( "sharing keys for session " + body.sender_key + "|" + body.session_id + " with device " + userId + ":" + deviceId, ); return this._buildKeyForwardingMessage( body.room_id, body.sender_key, body.session_id, ); }).then((payload) => { const encryptedContent = { algorithm: olmlib.OLM_ALGORITHM, sender_key: this._olmDevice.deviceCurve25519Key, ciphertext: {}, }; return this.olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this._userId, this._deviceId, this._olmDevice, userId, deviceInfo, payload, ).then(() => { const contentMap = { [userId]: { [deviceId]: encryptedContent, }, }; // TODO: retries return this._baseApis.sendToDevice("m.room.encrypted", contentMap); }); }); }; MegolmDecryption.prototype._buildKeyForwardingMessage = async function( roomId, senderKey, sessionId, ) { const key = await this._olmDevice.getInboundGroupSessionKey( roomId, senderKey, sessionId, ); return { type: "m.forwarded_room_key", content: { "algorithm": olmlib.MEGOLM_ALGORITHM, "room_id": roomId, "sender_key": senderKey, "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, "session_id": sessionId, "session_key": key.key, "chain_index": key.chain_index, "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, "org.matrix.msc3061.shared_history": key.shared_history || false, }, }; }; /** * @inheritdoc * * @param {module:crypto/OlmDevice.MegolmSessionData} session * @param {object} [opts={}] options for the import * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted * @param {string} [opts.source] where the key came from */ MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) { const extraSessionData = {}; if (opts.untrusted) { extraSessionData.untrusted = true; } if (session["org.matrix.msc3061.shared_history"]) { extraSessionData.sharedHistory = true; } return this._olmDevice.addInboundGroupSession( session.room_id, session.sender_key, session.forwarding_curve25519_key_chain, session.session_id, session.session_key, session.sender_claimed_keys, true, extraSessionData, ).then(() => { if (opts.source !== "backup") { // don't wait for it to complete this._crypto.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 megolm session", e); }); } // have another go at decrypting events sent with this session. this._retryDecryption(session.sender_key, session.session_id); }); }; /** * Have another go at decrypting events after we receive a key. Resolves once * decryption has been re-attempted on all events. * * @private * @param {String} senderKey * @param {String} sessionId * * @return {Boolean} whether all messages were successfully decrypted */ MegolmDecryption.prototype._retryDecryption = async function(senderKey, sessionId) { const senderPendingEvents = this._pendingEvents[senderKey]; if (!senderPendingEvents) { return true; } const pending = senderPendingEvents.get(sessionId); if (!pending) { return true; } logger.debug("Retrying decryption on events", [...pending]); await Promise.all([...pending].map(async (ev) => { try { await ev.attemptDecryption(this._crypto, true); } catch (e) { // don't die if something goes wrong } })); // If decrypted successfully, they'll have been removed from _pendingEvents return !((this._pendingEvents[senderKey] || {})[sessionId]); }; MegolmDecryption.prototype.retryDecryptionFromSender = async function(senderKey) { const senderPendingEvents = this._pendingEvents[senderKey]; if (!senderPendingEvents) { return true; } delete this._pendingEvents[senderKey]; await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { await Promise.all([...pending].map(async (ev) => { try { await ev.attemptDecryption(this._crypto); } catch (e) { // don't die if something goes wrong } })); })); return !this._pendingEvents[senderKey]; }; MegolmDecryption.prototype.sendSharedHistoryInboundSessions = async function(devicesByUser) { await olmlib.ensureOlmSessionsForDevices( this._olmDevice, this._baseApis, devicesByUser, ); logger.log("sendSharedHistoryInboundSessions to users", Object.keys(devicesByUser)); const sharedHistorySessions = await this._olmDevice.getSharedHistoryInboundGroupSessions( this._roomId, ); logger.log("shared-history sessions", sharedHistorySessions); for (const [senderKey, sessionId] of sharedHistorySessions) { const payload = await this._buildKeyForwardingMessage( this._roomId, senderKey, sessionId, ); const promises = []; const contentMap = {}; for (const [userId, devices] of Object.entries(devicesByUser)) { contentMap[userId] = {}; for (const deviceInfo of devices) { const encryptedContent = { algorithm: olmlib.OLM_ALGORITHM, sender_key: this._olmDevice.deviceCurve25519Key, ciphertext: {}, }; contentMap[userId][deviceInfo.deviceId] = encryptedContent; promises.push( olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this._userId, this._deviceId, this._olmDevice, userId, deviceInfo, payload, ), ); } } await Promise.all(promises); // prune out any devices that encryptMessageForDevice could not encrypt for, // in which case it will have just not added anything to the ciphertext object. // There's no point sending messages to devices if we couldn't encrypt to them, // since that's effectively a blank message. for (const userId of Object.keys(contentMap)) { for (const deviceId of Object.keys(contentMap[userId])) { if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { logger.log( "No ciphertext for device " + userId + ":" + deviceId + ": pruning", ); delete contentMap[userId][deviceId]; } } // No devices left for that user? Strip that too. if (Object.keys(contentMap[userId]).length === 0) { logger.log("Pruned all devices for user " + userId); delete contentMap[userId]; } } // Is there anything left? if (Object.keys(contentMap).length === 0) { logger.log("No users left to send to: aborting"); return; } await this._baseApis.sendToDevice("m.room.encrypted", contentMap); } }; registerAlgorithm( olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption, ); matrix-js-sdk-9.11.0/src/crypto/algorithms/olm.js000066400000000000000000000260471403504662500217220ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Defines m.olm encryption/decryption * * @module crypto/algorithms/olm */ import {logger} from '../../logger'; import * as utils from "../../utils"; import {polyfillSuper} from "../../utils"; import * as olmlib from "../olmlib"; import {DeviceInfo} from "../deviceinfo"; import { DecryptionAlgorithm, DecryptionError, EncryptionAlgorithm, registerAlgorithm, } from "./base"; const DeviceVerification = DeviceInfo.DeviceVerification; /** * Olm encryption implementation * * @constructor * @extends {module:crypto/algorithms/EncryptionAlgorithm} * * @param {object} params parameters, as per * {@link module:crypto/algorithms/EncryptionAlgorithm} */ function OlmEncryption(params) { polyfillSuper(this, EncryptionAlgorithm, params); this._sessionPrepared = false; this._prepPromise = null; } utils.inherits(OlmEncryption, EncryptionAlgorithm); /** * @private * @param {string[]} roomMembers list of currently-joined users in the room * @return {Promise} Promise which resolves when setup is complete */ OlmEncryption.prototype._ensureSession = function(roomMembers) { if (this._prepPromise) { // prep already in progress return this._prepPromise; } if (this._sessionPrepared) { // prep already done return Promise.resolve(); } const self = this; this._prepPromise = self._crypto.downloadKeys(roomMembers).then(function(res) { return self._crypto.ensureOlmSessionsForUsers(roomMembers); }).then(function() { self._sessionPrepared = true; }).finally(function() { self._prepPromise = null; }); return this._prepPromise; }; /** * @inheritdoc * * @param {module:models/room} room * @param {string} eventType * @param {object} content plaintext event content * * @return {Promise} Promise which resolves to the new event body */ OlmEncryption.prototype.encryptMessage = async function(room, eventType, content) { // pick the list of recipients based on the membership list. // // TODO: there is a race condition here! What if a new user turns up // just as you are sending a secret message? const members = await room.getEncryptionTargetMembers(); const users = 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).then(() => encryptedContent); }; /** * Olm decryption implementation * * @constructor * @extends {module:crypto/algorithms/DecryptionAlgorithm} * @param {object} params parameters, as per * {@link module:crypto/algorithms/DecryptionAlgorithm} */ function OlmDecryption(params) { polyfillSuper(this, DecryptionAlgorithm, params); } utils.inherits(OlmDecryption, DecryptionAlgorithm); /** * @inheritdoc * * @param {MatrixEvent} event * * returns a promise which resolves to a * {@link module:crypto~EventDecryptionResult} once we have finished * decrypting. Rejects with an `algorithms.DecryptionError` if there is a * problem decrypting the event. */ OlmDecryption.prototype.decryptEvent = async function(event) { const content = event.getWireContent(); const deviceKey = content.sender_key; const ciphertext = content.ciphertext; if (!ciphertext) { throw new DecryptionError( "OLM_MISSING_CIPHERTEXT", "Missing ciphertext", ); } if (!(this._olmDevice.deviceCurve25519Key in ciphertext)) { throw new DecryptionError( "OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients", ); } const message = ciphertext[this._olmDevice.deviceCurve25519Key]; let payloadString; try { payloadString = await this._decryptMessage(deviceKey, message); } catch (e) { throw new DecryptionError( "OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", { sender: deviceKey, err: e, }, ); } const payload = JSON.parse(payloadString); // check that we were the intended recipient, to avoid unknown-key attack // https://github.com/vector-im/vector-web/issues/2483 if (payload.recipient != this._userId) { throw new DecryptionError( "OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient, ); } if (payload.recipient_keys.ed25519 != this._olmDevice.deviceEd25519Key) { throw new DecryptionError( "OLM_BAD_RECIPIENT_KEY", "Message not intended for this device", { intended: payload.recipient_keys.ed25519, our_key: this._olmDevice.deviceEd25519Key, }, ); } // check that the original sender matches what the homeserver told us, to // avoid people masquerading as others. // (this check is also provided via the sender's embedded ed25519 key, // which is checked elsewhere). if (payload.sender != event.getSender()) { throw new DecryptionError( "OLM_FORWARDED_MESSAGE", "Message forwarded from " + payload.sender, { reported_sender: event.getSender(), }, ); } // Olm events intended for a room have a room_id. if (payload.room_id !== event.getRoomId()) { throw new DecryptionError( "OLM_BAD_ROOM", "Message intended for room " + payload.room_id, { reported_room: event.room_id, }, ); } const claimedKeys = payload.keys || {}; return { clearEvent: payload, senderCurve25519Key: deviceKey, claimedEd25519Key: claimedKeys.ed25519 || null, }; }; /** * Attempt to decrypt an Olm message * * @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender * @param {object} message message object, with 'type' and 'body' fields * * @return {string} payload, if decrypted successfully. */ OlmDecryption.prototype._decryptMessage = async function( theirDeviceIdentityKey, message, ) { // This is a wrapper that serialises decryptions of prekey messages, because // otherwise we race between deciding we have no active sessions for the message // and creating a new one, which we can only do once because it removes the OTK. if (message.type !== 0) { // not a prekey message: we can safely just try & decrypt it return this._reallyDecryptMessage(theirDeviceIdentityKey, message); } else { const myPromise = this._olmDevice._olmPrekeyPromise.then(() => { return this._reallyDecryptMessage(theirDeviceIdentityKey, message); }); // we want the error, but don't propagate it to the next decryption this._olmDevice._olmPrekeyPromise = myPromise.catch(() => {}); return await myPromise; } }; OlmDecryption.prototype._reallyDecryptMessage = async function( theirDeviceIdentityKey, message, ) { const sessionIds = await this._olmDevice.getSessionIdsForDevice( theirDeviceIdentityKey, ); // try each session in turn. const decryptionErrors = {}; for (let i = 0; i < sessionIds.length; i++) { const sessionId = sessionIds[i]; try { const payload = await this._olmDevice.decryptMessage( theirDeviceIdentityKey, sessionId, message.type, message.body, ); logger.log( "Decrypted Olm message from " + theirDeviceIdentityKey + " with session " + sessionId, ); return payload; } catch (e) { const foundSession = await this._olmDevice.matchesSession( theirDeviceIdentityKey, sessionId, message.type, message.body, ); if (foundSession) { // decryption failed, but it was a prekey message matching this // session, so it should have worked. throw new Error( "Error decrypting prekey message with existing session id " + sessionId + ": " + e.message, ); } // otherwise it's probably a message for another session; carry on, but // keep a record of the error decryptionErrors[sessionId] = e.message; } } if (message.type !== 0) { // not a prekey message, so it should have matched an existing session, but it // didn't work. if (sessionIds.length === 0) { throw new Error("No existing sessions"); } throw new Error( "Error decrypting non-prekey message with existing sessions: " + JSON.stringify(decryptionErrors), ); } // prekey message which doesn't match any existing sessions: make a new // session. let res; try { res = await this._olmDevice.createInboundSession( theirDeviceIdentityKey, message.type, message.body, ); } catch (e) { decryptionErrors["(new)"] = e.message; throw new Error( "Error decrypting prekey message: " + JSON.stringify(decryptionErrors), ); } logger.log( "created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey, ); return res.payload; }; registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); matrix-js-sdk-9.11.0/src/crypto/dehydration.ts000066400000000000000000000253411403504662500213020ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {decodeBase64, encodeBase64} from './olmlib'; import {IndexedDBCryptoStore} from '../crypto/store/indexeddb-crypto-store'; import {decryptAES, encryptAES} from './aes'; import anotherjson from "another-json"; import {logger} from '../logger'; // FIXME: these types should eventually go in a different file type Signatures = Record>; interface DeviceKeys { algorithms: Array; device_id: string; // eslint-disable-line camelcase user_id: string; // eslint-disable-line camelcase keys: Record; signatures?: Signatures; } interface OneTimeKey { key: string; fallback?: boolean; signatures?: Signatures; } export const DEHYDRATION_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle"; const oneweek = 7 * 24 * 60 * 60 * 1000; export class DehydrationManager { private inProgress = false; private timeoutId: any; private key: Uint8Array; private keyInfo: {[props: string]: any}; private deviceDisplayName: string; constructor(private crypto) { this.getDehydrationKeyFromCache(); } async getDehydrationKeyFromCache(): Promise { return await this.crypto._cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { this.crypto._cryptoStore.getSecretStorePrivateKey( txn, async (result) => { if (result) { const {key, keyInfo, deviceDisplayName, time} = result; const pickleKey = Buffer.from(this.crypto._olmDevice._pickleKey); const decrypted = await decryptAES(key, pickleKey, DEHYDRATION_ALGORITHM); this.key = decodeBase64(decrypted); this.keyInfo = keyInfo; this.deviceDisplayName = deviceDisplayName; const now = Date.now(); const delay = Math.max(1, time + oneweek - now); this.timeoutId = global.setTimeout( this.dehydrateDevice.bind(this), delay, ); } }, "dehydration", ); }, ); } /** set the key, and queue periodic dehydration to the server in the background */ async setKeyAndQueueDehydration( key: Uint8Array, keyInfo: {[props: string]: any} = {}, deviceDisplayName: string = undefined, ): Promise { const matches = await this.setKey(key, keyInfo, deviceDisplayName); if (!matches) { // start dehydration in the background this.dehydrateDevice(); } } async setKey( key: Uint8Array, keyInfo: {[props: string]: any} = {}, deviceDisplayName: string = undefined, ): Promise { if (!key) { // unsetting the key -- cancel any pending dehydration task if (this.timeoutId) { global.clearTimeout(this.timeoutId); this.timeoutId = undefined; } // clear storage await this.crypto._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { this.crypto._cryptoStore.storeSecretStorePrivateKey( txn, "dehydration", null, ); }, ); this.key = undefined; this.keyInfo = undefined; return; } // Check to see if it's the same key as before. If it's different, // dehydrate a new device. If it's the same, we can keep the same // device. (Assume that keyInfo and deviceDisplayName will be the // same if the key is the same.) let matches: boolean = this.key && key.length == this.key.length; for (let i = 0; matches && i < key.length; i++) { if (key[i] != this.key[i]) { matches = false; } } if (!matches) { this.key = key; this.keyInfo = keyInfo; this.deviceDisplayName = deviceDisplayName; } return matches; } /** returns the device id of the newly created dehydrated device */ async dehydrateDevice(): Promise { if (this.inProgress) { logger.log("Dehydration already in progress -- not starting new dehydration"); return; } this.inProgress = true; if (this.timeoutId) { global.clearTimeout(this.timeoutId); this.timeoutId = undefined; } try { const pickleKey = Buffer.from(this.crypto._olmDevice._pickleKey); // update the crypto store with the timestamp const key = await encryptAES(encodeBase64(this.key), pickleKey, DEHYDRATION_ALGORITHM); await this.crypto._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { this.crypto._cryptoStore.storeSecretStorePrivateKey( txn, "dehydration", { keyInfo: this.keyInfo, key, deviceDisplayName: this.deviceDisplayName, time: Date.now(), }, ); }, ); logger.log("Attempting to dehydrate device"); logger.log("Creating account"); // create the account and all the necessary keys const account = new global.Olm.Account(); account.create(); const e2eKeys = JSON.parse(account.identity_keys()); const maxKeys = account.max_number_of_one_time_keys(); // FIXME: generate in small batches? account.generate_one_time_keys(maxKeys / 2); account.generate_fallback_key(); const otks: Record = JSON.parse(account.one_time_keys()); const fallbacks: Record = JSON.parse(account.fallback_key()); account.mark_keys_as_published(); // dehydrate the account and store it on the server const pickledAccount = account.pickle(new Uint8Array(this.key)); const deviceData: {[props: string]: any} = { algorithm: DEHYDRATION_ALGORITHM, account: pickledAccount, }; if (this.keyInfo.passphrase) { deviceData.passphrase = this.keyInfo.passphrase; } logger.log("Uploading account to server"); const dehydrateResult = await this.crypto._baseApis._http.authedRequest( undefined, "PUT", "/dehydrated_device", undefined, { device_data: deviceData, initial_device_display_name: this.deviceDisplayName, }, { prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2", }, ); // send the keys to the server const deviceId = dehydrateResult.device_id; logger.log("Preparing device keys", deviceId); const deviceKeys: DeviceKeys = { algorithms: this.crypto._supportedAlgorithms, device_id: deviceId, user_id: this.crypto._userId, keys: { [`ed25519:${deviceId}`]: e2eKeys.ed25519, [`curve25519:${deviceId}`]: e2eKeys.curve25519, }, }; const deviceSignature = account.sign(anotherjson.stringify(deviceKeys)); deviceKeys.signatures = { [this.crypto._userId]: { [`ed25519:${deviceId}`]: deviceSignature, }, }; if (this.crypto._crossSigningInfo.getId("self_signing")) { await this.crypto._crossSigningInfo.signObject(deviceKeys, "self_signing"); } logger.log("Preparing one-time keys"); const oneTimeKeys = {}; for (const [keyId, key] of Object.entries(otks.curve25519)) { const k: OneTimeKey = {key}; const signature = account.sign(anotherjson.stringify(k)); k.signatures = { [this.crypto._userId]: { [`ed25519:${deviceId}`]: signature, }, }; oneTimeKeys[`signed_curve25519:${keyId}`] = k; } logger.log("Preparing fallback keys"); const fallbackKeys = {}; for (const [keyId, key] of Object.entries(fallbacks.curve25519)) { const k: OneTimeKey = {key, fallback: true}; const signature = account.sign(anotherjson.stringify(k)); k.signatures = { [this.crypto._userId]: { [`ed25519:${deviceId}`]: signature, }, }; fallbackKeys[`signed_curve25519:${keyId}`] = k; } logger.log("Uploading keys to server"); await this.crypto._baseApis._http.authedRequest( undefined, "POST", "/keys/upload/" + encodeURI(deviceId), undefined, { "device_keys": deviceKeys, "one_time_keys": oneTimeKeys, "org.matrix.msc2732.fallback_keys": fallbackKeys, }, ); logger.log("Done dehydrating"); // dehydrate again in a week this.timeoutId = global.setTimeout( this.dehydrateDevice.bind(this), oneweek, ); return deviceId; } finally { this.inProgress = false; } } private stop() { if (this.timeoutId) { global.clearTimeout(this.timeoutId); this.timeoutId = undefined; } } } matrix-js-sdk-9.11.0/src/crypto/deviceinfo.js000066400000000000000000000103361403504662500210670ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module crypto/deviceinfo */ /** * Information about a user's device * * @constructor * @alias module:crypto/deviceinfo * * @property {string} deviceId the ID of this device * * @property {string[]} algorithms list of algorithms supported by this device * * @property {Object.} keys a map from * <key type>:<id> -> <base64-encoded key>> * * @property {module:crypto/deviceinfo.DeviceVerification} verified * whether the device has been verified/blocked by the user * * @property {boolean} known * whether the user knows of this device's existence (useful when warning * the user that a user has added new devices) * * @property {Object} unsigned additional data from the homeserver * * @param {string} deviceId id of the device */ export function DeviceInfo(deviceId) { // you can't change the deviceId Object.defineProperty(this, 'deviceId', { enumerable: true, value: deviceId, }); this.algorithms = []; this.keys = {}; this.verified = DeviceVerification.UNVERIFIED; this.known = false; this.unsigned = {}; this.signatures = {}; } /** * rehydrate a DeviceInfo from the session store * * @param {object} obj raw object from session store * @param {string} deviceId id of the device * * @return {module:crypto~DeviceInfo} new DeviceInfo */ DeviceInfo.fromStorage = function(obj, deviceId) { const res = new DeviceInfo(deviceId); for (const prop in obj) { if (obj.hasOwnProperty(prop)) { res[prop] = obj[prop]; } } return res; }; /** * Prepare a DeviceInfo for JSON serialisation in the session store * * @return {object} deviceinfo with non-serialised members removed */ DeviceInfo.prototype.toStorage = function() { return { algorithms: this.algorithms, keys: this.keys, verified: this.verified, known: this.known, unsigned: this.unsigned, signatures: this.signatures, }; }; /** * Get the fingerprint for this device (ie, the Ed25519 key) * * @return {string} base64-encoded fingerprint of this device */ DeviceInfo.prototype.getFingerprint = function() { return this.keys["ed25519:" + this.deviceId]; }; /** * Get the identity key for this device (ie, the Curve25519 key) * * @return {string} base64-encoded identity key of this device */ DeviceInfo.prototype.getIdentityKey = function() { return this.keys["curve25519:" + this.deviceId]; }; /** * Get the configured display name for this device, if any * * @return {string?} displayname */ DeviceInfo.prototype.getDisplayName = function() { return this.unsigned.device_display_name || null; }; /** * Returns true if this device is blocked * * @return {Boolean} true if blocked */ DeviceInfo.prototype.isBlocked = function() { return this.verified == DeviceVerification.BLOCKED; }; /** * Returns true if this device is verified * * @return {Boolean} true if verified */ DeviceInfo.prototype.isVerified = function() { return this.verified == DeviceVerification.VERIFIED; }; /** * Returns true if this device is unverified * * @return {Boolean} true if unverified */ DeviceInfo.prototype.isUnverified = function() { return this.verified == DeviceVerification.UNVERIFIED; }; /** * Returns true if the user knows about this device's existence * * @return {Boolean} true if known */ DeviceInfo.prototype.isKnown = function() { return this.known == true; }; /** * @enum */ DeviceInfo.DeviceVerification = { VERIFIED: 1, UNVERIFIED: 0, BLOCKED: -1, }; const DeviceVerification = DeviceInfo.DeviceVerification; matrix-js-sdk-9.11.0/src/crypto/index.js000066400000000000000000004342441403504662500200730ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018-2019 New Vector Ltd Copyright 2019-2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module crypto */ import anotherjson from "another-json"; import {EventEmitter} from 'events'; import {ReEmitter} from '../ReEmitter'; import {logger} from '../logger'; import * as utils from "../utils"; import {sleep} from "../utils"; import {OlmDevice} from "./OlmDevice"; import * as olmlib from "./olmlib"; import {DeviceList} from "./DeviceList"; import {DeviceInfo} from "./deviceinfo"; import * as algorithms from "./algorithms"; import { CrossSigningInfo, DeviceTrustLevel, UserTrustLevel, createCryptoStoreCacheCallbacks, } from './CrossSigning'; import {EncryptionSetupBuilder} from "./EncryptionSetup"; import {SECRET_STORAGE_ALGORITHM_V1_AES, SecretStorage} from './SecretStorage'; import {OutgoingRoomKeyRequestManager} from './OutgoingRoomKeyRequestManager'; import {IndexedDBCryptoStore} from './store/indexeddb-crypto-store'; import { ReciprocateQRCode, SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD, } from './verification/QRCode'; import {SAS} from './verification/SAS'; import {keyFromPassphrase} from './key_passphrase'; import {encodeRecoveryKey, decodeRecoveryKey} from './recoverykey'; import {VerificationRequest} from "./verification/request/VerificationRequest"; import {InRoomChannel, InRoomRequests} from "./verification/request/InRoomChannel"; import {ToDeviceChannel, ToDeviceRequests} from "./verification/request/ToDeviceChannel"; import {IllegalMethod} from "./verification/IllegalMethod"; import {KeySignatureUploadError} from "../errors"; import {decryptAES, encryptAES} from './aes'; import {DehydrationManager} from './dehydration'; import { MatrixEvent } from "../models/event"; const DeviceVerification = DeviceInfo.DeviceVerification; const defaultVerificationMethods = { [ReciprocateQRCode.NAME]: ReciprocateQRCode, [SAS.NAME]: SAS, // These two can't be used for actual verification, but we do // need to be able to define them here for the verification flows // to start. [SHOW_QR_CODE_METHOD]: IllegalMethod, [SCAN_QR_CODE_METHOD]: IllegalMethod, }; /** * verification method names */ export const verificationMethods = { RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME, SAS: SAS.NAME, }; export function isCryptoAvailable() { return Boolean(global.Olm); } const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; 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 function Crypto(baseApis, sessionStore, userId, deviceId, clientStore, cryptoStore, roomList, verificationMethods) { this._onDeviceListUserCrossSigningUpdated = this._onDeviceListUserCrossSigningUpdated.bind(this); this._trustCrossSignedDevices = true; this._reEmitter = new ReEmitter(this); this._baseApis = baseApis; this._sessionStore = sessionStore; this._userId = userId; this._deviceId = deviceId; this._clientStore = clientStore; this._cryptoStore = cryptoStore; this._roomList = roomList; if (verificationMethods) { this._verificationMethods = new Map(); for (const method of verificationMethods) { if (typeof method === "string") { if (defaultVerificationMethods[method]) { this._verificationMethods.set( method, defaultVerificationMethods[method], ); } } else if (method.NAME) { this._verificationMethods.set( method.NAME, method, ); } else { logger.warn(`Excluding unknown verification method ${method}`); } } } else { this._verificationMethods = defaultVerificationMethods; } // 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, ); // XXX: This isn't removed at any point, but then none of the event listeners // this class sets seem to be removed at any point... :/ this._deviceList.on( 'userCrossSigningUpdated', this._onDeviceListUserCrossSigningUpdated, ); this._reEmitter.reEmit(this._deviceList, [ "crypto.devicesUpdated", "crypto.willUpdateDevices", ]); // the last time we did a check for the number of one-time-keys on the // server. this._lastOneTimeKeyCheck = null; this._oneTimeKeyCheckInProgress = false; // EncryptionAlgorithm instance for each room this._roomEncryptors = {}; // map from algorithm to DecryptionAlgorithm instance, for each room this._roomDecryptors = {}; this._supportedAlgorithms = utils.keys( algorithms.DECRYPTION_CLASSES, ); this._deviceKeys = {}; this._globalBlacklistUnverifiedDevices = false; this._globalErrorOnUnknownDevices = true; this._outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager( baseApis, this._deviceId, this._cryptoStore, ); // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations // we received in the current sync. this._receivedRoomKeyRequests = []; this._receivedRoomKeyRequestCancellations = []; // true if we are currently processing received room key requests this._processingRoomKeyRequests = false; // controls whether device tracking is delayed // until calling encryptEvent or trackRoomDevices, // or done immediately upon enabling room encryption. this._lazyLoadMembers = false; // in case _lazyLoadMembers is true, // track if an initial tracking of all the room members // has happened for a given room. This is delayed // to avoid loading room members as long as possible. this._roomDeviceTrackingState = {}; // The timestamp of the last time we forced establishment // of a new session for each device, in milliseconds. // { // userId: { // deviceId: 1234567890000, // }, // } this._lastNewSessionForced = {}; this._toDeviceVerificationRequests = new ToDeviceRequests(); this._inRoomVerificationRequests = new InRoomRequests(); // This flag will be unset whilst the client processes a sync response // so that we don't start requesting keys until we've actually finished // processing the response. this._sendKeyRequestsImmediately = false; const cryptoCallbacks = this._baseApis._cryptoCallbacks || {}; const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this._olmDevice); this._crossSigningInfo = new CrossSigningInfo( userId, cryptoCallbacks, cacheCallbacks, ); this._secretStorage = new SecretStorage( baseApis, cryptoCallbacks, ); this._dehydrationManager = new DehydrationManager(this); // Assuming no app-supplied callback, default to getting from SSSS. if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) { cryptoCallbacks.getCrossSigningKey = async (type) => { return CrossSigningInfo.getFromSecretStorage(type, this._secretStorage); }; } } utils.inherits(Crypto, EventEmitter); /** * Initialise the crypto module so that it is ready for use * * Returns a promise which resolves once the crypto module is ready for use. * * @param {Object} opts keyword arguments. * @param {string} opts.exportedOlmDevice (Optional) data from exported device * that must be re-created. */ Crypto.prototype.init = async function(opts) { const { exportedOlmDevice, pickleKey, } = opts || {}; logger.log("Crypto: initialising Olm..."); await global.Olm.init(); logger.log( exportedOlmDevice ? "Crypto: initialising Olm device from exported device..." : "Crypto: initialising Olm device...", ); await this._olmDevice.init({ fromExportedDevice: exportedOlmDevice, pickleKey }); logger.log("Crypto: loading device list..."); await this._deviceList.load(); // build our device keys: these will later be uploaded this._deviceKeys["ed25519:" + this._deviceId] = this._olmDevice.deviceEd25519Key; this._deviceKeys["curve25519:" + this._deviceId] = this._olmDevice.deviceCurve25519Key; logger.log("Crypto: fetching own devices..."); let myDevices = this._deviceList.getRawStoredDevicesForUser( this._userId, ); if (!myDevices) { myDevices = {}; } if (!myDevices[this._deviceId]) { // add our own deviceinfo to the cryptoStore logger.log("Crypto: adding this device to the store..."); const deviceInfo = { keys: this._deviceKeys, algorithms: this._supportedAlgorithms, verified: DeviceVerification.VERIFIED, known: true, }; myDevices[this._deviceId] = deviceInfo; this._deviceList.storeDevicesForUser( this._userId, myDevices, ); this._deviceList.saveIfDirty(); } await this._cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { this._cryptoStore.getCrossSigningKeys(txn, (keys) => { // can be an empty object after resetting cross-signing keys, see _storeTrustedSelfKeys if (keys && Object.keys(keys).length !== 0) { logger.log("Loaded cross-signing public keys from crypto store"); this._crossSigningInfo.setKeys(keys); } }); }, ); // make sure we are keeping track of our own devices // (this is important for key backups & things) this._deviceList.startTrackingDeviceList(this._userId); logger.log("Crypto: checking for key backup..."); this._checkAndStartKeyBackup(); }; /** * Whether to trust a others users signatures of their devices. * If false, devices will only be considered 'verified' if we have * verified that device individually (effectively disabling cross-signing). * * Default: true * * @return {bool} True if trusting cross-signed devices */ Crypto.prototype.getCryptoTrustCrossSignedDevices = function() { return this._trustCrossSignedDevices; }; /** * See getCryptoTrustCrossSignedDevices * This may be set before initCrypto() is called to ensure no races occur. * * @param {bool} val True to trust cross-signed devices */ Crypto.prototype.setCryptoTrustCrossSignedDevices = function(val) { this._trustCrossSignedDevices = val; for (const userId of this._deviceList.getKnownUserIds()) { const devices = this._deviceList.getRawStoredDevicesForUser(userId); for (const deviceId of Object.keys(devices)) { const deviceTrust = this.checkDeviceTrust(userId, deviceId); // If the device is locally verified then isVerified() is always true, // so this will only have caused the value to change if the device is // cross-signing verified but not locally verified if ( !deviceTrust.isLocallyVerified() && deviceTrust.isCrossSigningVerified() ) { const deviceObj = this._deviceList.getStoredDevice(userId, deviceId); this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); } } } }; /** * Create a recovery key from a user-supplied passphrase. * * @param {string} password Passphrase string that can be entered by the user * when restoring the backup as an alternative to entering the recovery key. * Optional. * @returns {Promise} Object with public key metadata, encoded private * recovery key which should be disposed of after displaying to the user, * and raw private key to avoid round tripping if needed. */ Crypto.prototype.createRecoveryKeyFromPassphrase = async function(password) { const decryption = new global.Olm.PkDecryption(); try { const keyInfo = {}; if (password) { const derivation = await keyFromPassphrase(password); keyInfo.passphrase = { algorithm: "m.pbkdf2", iterations: derivation.iterations, salt: derivation.salt, }; keyInfo.pubkey = decryption.init_with_private_key(derivation.key); } else { keyInfo.pubkey = decryption.generate_key(); } const privateKey = decryption.get_private_key(); const encodedPrivateKey = encodeRecoveryKey(privateKey); return { keyInfo, encodedPrivateKey, privateKey }; } finally { if (decryption) decryption.free(); } }; /** * Checks whether cross signing: * - is enabled on this account and trusted by this device * - has private keys either cached locally or stored in secret storage * * If this function returns false, bootstrapCrossSigning() can be used * to fix things such that it returns true. That is to say, after * bootstrapCrossSigning() completes successfully, this function should * return true. * * The cross-signing API is currently UNSTABLE and may change without notice. * * @return {bool} True if cross-signing is ready to be used on this device */ Crypto.prototype.isCrossSigningReady = async function() { const publicKeysOnDevice = this._crossSigningInfo.getId(); const privateKeysExistSomewhere = ( await this._crossSigningInfo.isStoredInKeyCache() || await this._crossSigningInfo.isStoredInSecretStorage( this._secretStorage, ) ); return !!( publicKeysOnDevice && privateKeysExistSomewhere ); }; /** * Checks whether secret storage: * - is enabled on this account * - is storing cross-signing private keys * - is storing session backup key (if enabled) * * If this function returns false, bootstrapSecretStorage() can be used * to fix things such that it returns true. That is to say, after * bootstrapSecretStorage() completes successfully, this function should * return true. * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @return {bool} True if secret storage is ready to be used on this device */ Crypto.prototype.isSecretStorageReady = async function() { const secretStorageKeyInAccount = await this._secretStorage.hasKey(); const privateKeysInStorage = await this._crossSigningInfo.isStoredInSecretStorage( this._secretStorage, ); const sessionBackupInStorage = ( !this._baseApis.getKeyBackupEnabled() || this._baseApis.isKeyBackupKeyStored() ); return !!( secretStorageKeyInAccount && privateKeysInStorage && sessionBackupInStorage ); }; /** * Bootstrap cross-signing by creating keys if needed. If everything is already * set up, then no changes are made, so this is safe to run to ensure * cross-signing is ready for use. * * This function: * - creates new cross-signing keys if they are not found locally cached nor in * secret storage (if it has been setup) * * The cross-signing API is currently UNSTABLE and may change without notice. * * @param {function} opts.authUploadDeviceSigningKeys Function * called to await an interactive auth flow when uploading device signing keys. * @param {bool} [opts.setupNewCrossSigning] Optional. Reset even if keys * already exist. * Args: * {function} A function that makes the request requiring auth. Receives the * auth data as an object. Can be called multiple times, first with an empty * authDict, to obtain the flows. */ Crypto.prototype.bootstrapCrossSigning = async function({ authUploadDeviceSigningKeys, setupNewCrossSigning, } = {}) { logger.log("Bootstrapping cross-signing"); const delegateCryptoCallbacks = this._baseApis._cryptoCallbacks; const builder = new EncryptionSetupBuilder( this._baseApis.store.accountData, delegateCryptoCallbacks, ); const crossSigningInfo = new CrossSigningInfo( this._userId, builder.crossSigningCallbacks, builder.crossSigningCallbacks, ); // Reset the cross-signing keys const resetCrossSigning = async () => { crossSigningInfo.resetKeys(); // Sign master key with device key await this._signObject(crossSigningInfo.keys.master); // Store auth flow helper function, as we need to call it when uploading // to ensure we handle auth errors properly. builder.addCrossSigningKeys(authUploadDeviceSigningKeys, crossSigningInfo.keys); // Cross-sign own device const device = this._deviceList.getStoredDevice(this._userId, this._deviceId); const deviceSignature = await crossSigningInfo.signDevice(this._userId, device); builder.addKeySignature(this._userId, this._deviceId, deviceSignature); // Sign message key backup with cross-signing master key if (this.backupInfo) { await crossSigningInfo.signObject(this.backupInfo.auth_data, "master"); builder.addSessionBackup(this.backupInfo); } }; const publicKeysOnDevice = this._crossSigningInfo.getId(); const privateKeysInCache = await this._crossSigningInfo.isStoredInKeyCache(); const privateKeysInStorage = await this._crossSigningInfo.isStoredInSecretStorage( this._secretStorage, ); const privateKeysExistSomewhere = ( privateKeysInCache || privateKeysInStorage ); // Log all relevant state for easier parsing of debug logs. logger.log({ setupNewCrossSigning, publicKeysOnDevice, privateKeysInCache, privateKeysInStorage, privateKeysExistSomewhere, }); if (!privateKeysExistSomewhere || setupNewCrossSigning) { logger.log( "Cross-signing private keys not found locally or in secret storage, " + "creating new keys", ); // If a user has multiple devices, it important to only call bootstrap // as part of some UI flow (and not silently during startup), as they // may have setup cross-signing on a platform which has not saved keys // to secret storage, and this would reset them. In such a case, you // should prompt the user to verify any existing devices first (and // request private keys from those devices) before calling bootstrap. await resetCrossSigning(); } else if (publicKeysOnDevice && privateKeysInCache) { logger.log( "Cross-signing public keys trusted and private keys found locally", ); } else if (privateKeysInStorage) { logger.log( "Cross-signing private keys not found locally, but they are available " + "in secret storage, reading storage and caching locally", ); await this.checkOwnCrossSigningTrust({ allowPrivateKeyRequests: true, }); } // Assuming no app-supplied callback, default to storing new private keys in // secret storage if it exists. If it does not, it is assumed this will be // done as part of setting up secret storage later. const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys; if ( crossSigningPrivateKeys.size && !this._baseApis._cryptoCallbacks.saveCrossSigningKeys ) { const secretStorage = new SecretStorage( builder.accountDataClientAdapter, builder.ssssCryptoCallbacks); if (await secretStorage.hasKey()) { logger.log("Storing new cross-signing private keys in secret storage"); // This is writing to in-memory account data in // builder.accountDataClientAdapter so won't fail await CrossSigningInfo.storeInSecretStorage( crossSigningPrivateKeys, secretStorage, ); } } const operation = builder.buildOperation(); await operation.apply(this); // This persists private keys and public keys as trusted, // only do this if apply succeeded for now as retry isn't in place yet await builder.persist(this); logger.log("Cross-signing ready"); }; /** * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is * already set up, then no changes are made, so this is safe to run to ensure secret * storage is ready for use. * * This function * - creates a new Secure Secret Storage key if no default key exists * - if a key backup exists, it is migrated to store the key in the Secret * Storage * - creates a backup if none exists, and one is requested * - migrates Secure Secret Storage to use the latest algorithm, if an outdated * algorithm is found * * The Secure Secret Storage API is currently UNSTABLE and may change without notice. * * @param {function} [opts.createSecretStorageKey] Optional. Function * called to await a secret storage key creation flow. * Returns: * {Promise} Object with public key metadata, encoded private * recovery key which should be disposed of after displaying to the user, * and raw private key to avoid round tripping if needed. * @param {object} [opts.keyBackupInfo] The current key backup object. If passed, * the passphrase and recovery key from this backup will be used. * @param {bool} [opts.setupNewKeyBackup] If true, a new key backup version will be * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo * is supplied. * @param {bool} [opts.setupNewSecretStorage] Optional. Reset even if keys already exist. * @param {func} [opts.getKeyBackupPassphrase] Optional. Function called to get the user's * current key backup passphrase. Should return a promise that resolves with a Buffer * containing the key, or rejects if the key cannot be obtained. * Returns: * {Promise} A promise which resolves to key creation data for * SecretStorage#addKey: an object with `passphrase` etc fields. */ Crypto.prototype.bootstrapSecretStorage = async function({ createSecretStorageKey = async () => ({ }), keyBackupInfo, setupNewKeyBackup, setupNewSecretStorage, getKeyBackupPassphrase, } = {}) { logger.log("Bootstrapping Secure Secret Storage"); const delegateCryptoCallbacks = this._baseApis._cryptoCallbacks; const builder = new EncryptionSetupBuilder( this._baseApis.store.accountData, delegateCryptoCallbacks, ); const secretStorage = new SecretStorage( builder.accountDataClientAdapter, builder.ssssCryptoCallbacks, ); // the ID of the new SSSS key, if we create one let newKeyId = null; // create a new SSSS key and set it as default const createSSSS = async (opts, privateKey) => { opts = opts || {}; if (privateKey) { opts.key = privateKey; } const { keyId, keyInfo } = await secretStorage.addKey( SECRET_STORAGE_ALGORITHM_V1_AES, opts, ); if (privateKey) { // make the private key available to encrypt 4S secrets builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); } await secretStorage.setDefaultKeyId(keyId); return keyId; }; const ensureCanCheckPassphrase = async (keyId, keyInfo) => { if (!keyInfo.mac) { const key = await this._baseApis._cryptoCallbacks.getSecretStorageKey( {keys: {[keyId]: keyInfo}}, "", ); if (key) { const privateKey = key[1]; builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); const {iv, mac} = await SecretStorage._calculateKeyCheck(privateKey); keyInfo.iv = iv; keyInfo.mac = mac; await builder.setAccountData( `m.secret_storage.key.${keyId}`, keyInfo, ); } } }; const signKeyBackupWithCrossSigning = async (keyBackupAuthData) => { if ( this._crossSigningInfo.getId() && await this._crossSigningInfo.isStoredInKeyCache("master") ) { try { logger.log("Adding cross-signing signature to key backup"); await this._crossSigningInfo.signObject(keyBackupAuthData, "master"); } catch (e) { // This step is not critical (just helpful), so we catch here // and continue if it fails. logger.error("Signing key backup with cross-signing keys failed", e); } } else { logger.warn( "Cross-signing keys not available, skipping signature on key backup", ); } }; const oldSSSSKey = await this.getSecretStorageKey(); const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null]; const storageExists = ( !setupNewSecretStorage && oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES ); // Log all relevant state for easier parsing of debug logs. logger.log({ keyBackupInfo, setupNewKeyBackup, setupNewSecretStorage, storageExists, oldKeyInfo, }); if (!storageExists && !keyBackupInfo) { // either we don't have anything, or we've been asked to restart // from scratch logger.log( "Secret storage does not exist, creating new storage key", ); // if we already have a usable default SSSS key and aren't resetting // SSSS just use it. otherwise, create a new one // Note: we leave the old SSSS key in place: there could be other // secrets using it, in theory. We could move them to the new key but a) // that would mean we'd need to prompt for the old passphrase, and b) // it's not clear that would be the right thing to do anyway. const { keyInfo, privateKey } = await createSecretStorageKey(); newKeyId = await createSSSS(keyInfo, privateKey); } else if (!storageExists && keyBackupInfo) { // we have an existing backup, but no SSSS logger.log("Secret storage does not exist, using key backup key"); // if we have the backup key already cached, use it; otherwise use the // callback to prompt for the key const backupKey = await this.getSessionBackupPrivateKey() || await getKeyBackupPassphrase(); // create a new SSSS key and use the backup key as the new SSSS key const opts = {}; if ( keyBackupInfo.auth_data.private_key_salt && keyBackupInfo.auth_data.private_key_iterations ) { opts.passphrase = { algorithm: "m.pbkdf2", iterations: keyBackupInfo.auth_data.private_key_iterations, salt: keyBackupInfo.auth_data.private_key_salt, bits: 256, }; } newKeyId = await createSSSS(opts, backupKey); // store the backup key in secret storage await secretStorage.store( "m.megolm_backup.v1", olmlib.encodeBase64(backupKey), [newKeyId], ); // The backup is trusted because the user provided the private key. // Sign the backup with the cross-signing key so the key backup can // be trusted via cross-signing. await signKeyBackupWithCrossSigning(keyBackupInfo.auth_data); builder.addSessionBackup(keyBackupInfo); } else { // 4S is already set up logger.log("Secret storage exists"); if (oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { // make sure that the default key has the information needed to // check the passphrase await ensureCanCheckPassphrase(oldKeyId, oldKeyInfo); } } // If we have cross-signing private keys cached, store them in secret // storage if they are not there already. if ( !this._baseApis._cryptoCallbacks.saveCrossSigningKeys && await this.isCrossSigningReady() && (newKeyId || !await this._crossSigningInfo.isStoredInSecretStorage(secretStorage)) ) { logger.log("Copying cross-signing private keys from cache to secret storage"); const crossSigningPrivateKeys = await this._crossSigningInfo.getCrossSigningKeysFromCache(); // This is writing to in-memory account data in // builder.accountDataClientAdapter so won't fail await CrossSigningInfo.storeInSecretStorage( crossSigningPrivateKeys, secretStorage, ); } if (setupNewKeyBackup && !keyBackupInfo) { logger.log("Creating new message key backup version"); const info = await this._baseApis.prepareKeyBackupVersion( null /* random key */, // don't write to secret storage, as it will write to this._secretStorage. // Here, we want to capture all the side-effects of bootstrapping, // and want to write to the local secretStorage object { secureSecretStorage: false }, ); // write the key ourselves to 4S const privateKey = decodeRecoveryKey(info.recovery_key); await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey)); // create keyBackupInfo object to add to builder const data = { algorithm: info.algorithm, auth_data: info.auth_data, }; // Sign with cross-signing master key await signKeyBackupWithCrossSigning(data.auth_data); // sign with the device fingerprint await this._signObject(data.auth_data); builder.addSessionBackup(data); } // Cache the session backup key const sessionBackupKey = await secretStorage.get('m.megolm_backup.v1'); if (sessionBackupKey) { logger.info("Got session backup key from secret storage: caching"); // fix up the backup key if it's in the wrong format, and replace // in secret storage const fixedBackupKey = fixBackupKey(sessionBackupKey); if (fixedBackupKey) { await secretStorage.store("m.megolm_backup.v1", fixedBackupKey, [newKeyId || oldKeyId], ); } const decodedBackupKey = new Uint8Array(olmlib.decodeBase64( fixedBackupKey || sessionBackupKey, )); await builder.addSessionBackupPrivateKeyToCache(decodedBackupKey); } const operation = builder.buildOperation(); await operation.apply(this); // this persists private keys and public keys as trusted, // only do this if apply succeeded for now as retry isn't in place yet await builder.persist(this); logger.log("Secure Secret Storage ready"); }; /** * Fix up the backup key, that may be in the wrong format due to a bug in a * migration step. Some backup keys were stored as a comma-separated list of * integers, rather than a base64-encoded byte array. If this function is * passed a string that looks like a list of integers rather than a base64 * string, it will attempt to convert it to the right format. * * @param {string} key the key to check * @returns {null | string} If the key is in the wrong format, then the fixed * key will be returned. Otherwise null will be returned. * */ export function fixBackupKey(key) { if (typeof key !== "string" || key.indexOf(",") < 0) { return null; } const fixedKey = Uint8Array.from(key.split(","), x => parseInt(x)); return olmlib.encodeBase64(fixedKey); } Crypto.prototype.addSecretStorageKey = function(algorithm, opts, keyID) { return this._secretStorage.addKey(algorithm, opts, keyID); }; Crypto.prototype.hasSecretStorageKey = function(keyID) { return this._secretStorage.hasKey(keyID); }; Crypto.prototype.getSecretStorageKey = function(keyID) { return this._secretStorage.getKey(keyID); }; Crypto.prototype.storeSecret = function(name, secret, keys) { return this._secretStorage.store(name, secret, keys); }; Crypto.prototype.getSecret = function(name) { return this._secretStorage.get(name); }; Crypto.prototype.isSecretStored = function(name, checkKey) { return this._secretStorage.isStored(name, checkKey); }; Crypto.prototype.requestSecret = function(name, devices) { if (!devices) { devices = Object.keys(this._deviceList.getRawStoredDevicesForUser(this._userId)); } return this._secretStorage.request(name, devices); }; Crypto.prototype.getDefaultSecretStorageKeyId = function() { return this._secretStorage.getDefaultKeyId(); }; Crypto.prototype.setDefaultSecretStorageKeyId = function(k) { return this._secretStorage.setDefaultKeyId(k); }; Crypto.prototype.checkSecretStorageKey = function(key, info) { return this._secretStorage.checkKey(key, info); }; /** * Checks that a given secret storage private key matches a given public key. * This can be used by the getSecretStorageKey callback to verify that the * private key it is about to supply is the one that was requested. * * @param {Uint8Array} privateKey The private key * @param {string} expectedPublicKey The public key * @returns {boolean} true if the key matches, otherwise false */ Crypto.prototype.checkSecretStoragePrivateKey = function(privateKey, expectedPublicKey) { let decryption = null; try { decryption = new global.Olm.PkDecryption(); const gotPubkey = decryption.init_with_private_key(privateKey); // make sure it agrees with the given pubkey return gotPubkey === expectedPublicKey; } finally { if (decryption) decryption.free(); } }; /** * Fetches the backup private key, if cached * @returns {Promise} the key, if any, or null */ Crypto.prototype.getSessionBackupPrivateKey = async function() { let key = await new Promise((resolve) => { this._cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { this._cryptoStore.getSecretStorePrivateKey( txn, resolve, "m.megolm_backup.v1", ); }, ); }); // make sure we have a Uint8Array, rather than a string if (key && typeof key === "string") { key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key)); await this.storeSessionBackupPrivateKey(key); } if (key && key.ciphertext) { const pickleKey = Buffer.from(this._olmDevice._pickleKey); const decrypted = await decryptAES(key, pickleKey, "m.megolm_backup.v1"); key = olmlib.decodeBase64(decrypted); } return key; }; /** * Stores the session backup key to the cache * @param {Uint8Array} key the private key * @returns {Promise} so you can catch failures */ Crypto.prototype.storeSessionBackupPrivateKey = async function(key) { if (!(key instanceof Uint8Array)) { throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`); } const pickleKey = Buffer.from(this._olmDevice._pickleKey); key = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); return this._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { this._cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", key); }, ); }; /** * Checks that a given cross-signing private key matches a given public key. * This can be used by the getCrossSigningKey callback to verify that the * private key it is about to supply is the one that was requested. * * @param {Uint8Array} privateKey The private key * @param {string} expectedPublicKey The public key * @returns {boolean} true if the key matches, otherwise false */ Crypto.prototype.checkCrossSigningPrivateKey = function(privateKey, expectedPublicKey) { let signing = null; try { signing = new global.Olm.PkSigning(); const gotPubkey = signing.init_with_seed(privateKey); // make sure it agrees with the given pubkey return gotPubkey === expectedPublicKey; } finally { if (signing) signing.free(); } }; /** * Run various follow-up actions after cross-signing keys have changed locally * (either by resetting the keys for the account or by getting them from secret * storage), such as signing the current device, upgrading device * verifications, etc. */ Crypto.prototype._afterCrossSigningLocalKeyChange = async function() { logger.info("Starting cross-signing key change post-processing"); // sign the current device with the new key, and upload to the server const device = this._deviceList.getStoredDevice(this._userId, this._deviceId); const signedDevice = await this._crossSigningInfo.signDevice(this._userId, device); logger.info(`Starting background key sig upload for ${this._deviceId}`); const upload = ({ shouldEmit }) => { return this._baseApis.uploadKeySignatures({ [this._userId]: { [this._deviceId]: signedDevice, }, }).then((response) => { const { failures } = response || {}; if (Object.keys(failures || []).length > 0) { if (shouldEmit) { this._baseApis.emit( "crypto.keySignatureUploadFailure", failures, "_afterCrossSigningLocalKeyChange", upload, // continuation ); } throw new KeySignatureUploadError("Key upload failed", { failures }); } logger.info(`Finished background key sig upload for ${this._deviceId}`); }).catch(e => { logger.error( `Error during background key sig upload for ${this._deviceId}`, e, ); }); }; upload({ shouldEmit: true }); const shouldUpgradeCb = ( this._baseApis._cryptoCallbacks.shouldUpgradeDeviceVerifications ); if (shouldUpgradeCb) { logger.info("Starting device verification upgrade"); // Check all users for signatures if upgrade callback present // FIXME: do this in batches const users = {}; for (const [userId, crossSigningInfo] of Object.entries(this._deviceList._crossSigningInfo)) { const upgradeInfo = await this._checkForDeviceVerificationUpgrade( userId, CrossSigningInfo.fromStorage(crossSigningInfo, userId), ); if (upgradeInfo) { users[userId] = upgradeInfo; } } if (Object.keys(users).length > 0) { logger.info(`Found ${Object.keys(users).length} verif users to upgrade`); try { const usersToUpgrade = await shouldUpgradeCb({ users: users }); if (usersToUpgrade) { for (const userId of usersToUpgrade) { if (userId in users) { await this._baseApis.setDeviceVerified( userId, users[userId].crossSigningInfo.getId(), ); } } } } catch (e) { logger.log( "shouldUpgradeDeviceVerifications threw an error: not upgrading", e, ); } } logger.info("Finished device verification upgrade"); } logger.info("Finished cross-signing key change post-processing"); }; /** * Check if a user's cross-signing key is a candidate for upgrading from device * verification. * * @param {string} userId the user whose cross-signing information is to be checked * @param {object} crossSigningInfo the cross-signing information to check */ Crypto.prototype._checkForDeviceVerificationUpgrade = async function( userId, crossSigningInfo, ) { // only upgrade if this is the first cross-signing key that we've seen for // them, and if their cross-signing key isn't already verified const trustLevel = this._crossSigningInfo.checkUserTrust(crossSigningInfo); if (crossSigningInfo.firstUse && !trustLevel.verified) { const devices = this._deviceList.getRawStoredDevicesForUser(userId); const deviceIds = await this._checkForValidDeviceSignature( userId, crossSigningInfo.keys.master, devices, ); if (deviceIds.length) { return { devices: deviceIds.map( deviceId => DeviceInfo.fromStorage(devices[deviceId], deviceId), ), crossSigningInfo, }; } } }; /** * Check if the cross-signing key is signed by a verified device. * * @param {string} userId the user ID whose key is being checked * @param {object} key the key that is being checked * @param {object} devices the user's devices. Should be a map from device ID * to device info */ Crypto.prototype._checkForValidDeviceSignature = async function(userId, key, devices) { const deviceIds = []; if (devices && key.signatures && key.signatures[userId]) { for (const signame of Object.keys(key.signatures[userId])) { const [, deviceId] = signame.split(':', 2); if (deviceId in devices && devices[deviceId].verified === DeviceVerification.VERIFIED) { try { await olmlib.verifySignature( this._olmDevice, key, userId, deviceId, devices[deviceId].keys[signame], ); deviceIds.push(deviceId); } catch (e) {} } } } return deviceIds; }; /** * Get the user's cross-signing key ID. * * @param {string} [type=master] The type of key to get the ID of. One of * "master", "self_signing", or "user_signing". Defaults to "master". * * @returns {string} the key ID */ Crypto.prototype.getCrossSigningId = function(type) { return this._crossSigningInfo.getId(type); }; /** * Get the cross signing information for a given user. * * @param {string} userId the user ID to get the cross-signing info for. * * @returns {CrossSigningInfo} the cross signing informmation for the user. */ Crypto.prototype.getStoredCrossSigningForUser = function(userId) { return this._deviceList.getStoredCrossSigningForUser(userId); }; /** * Check whether a given user is trusted. * * @param {string} userId The ID of the user to check. * * @returns {UserTrustLevel} */ Crypto.prototype.checkUserTrust = function(userId) { const userCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); if (!userCrossSigning) { return new UserTrustLevel(false, false, false); } return this._crossSigningInfo.checkUserTrust(userCrossSigning); }; /** * Check whether a given device is trusted. * * @param {string} userId The ID of the user whose devices is to be checked. * @param {string} deviceId The ID of the device to check * * @returns {DeviceTrustLevel} */ Crypto.prototype.checkDeviceTrust = function(userId, deviceId) { const device = this._deviceList.getStoredDevice(userId, deviceId); return this._checkDeviceInfoTrust(userId, device); }; /** * Check whether a given deviceinfo is trusted. * * @param {string} userId The ID of the user whose devices is to be checked. * @param {module:crypto/deviceinfo?} device The device info object to check * * @returns {DeviceTrustLevel} */ Crypto.prototype._checkDeviceInfoTrust = function(userId, device) { const trustedLocally = !!(device && device.isVerified()); const userCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); if (device && userCrossSigning) { // The _trustCrossSignedDevices only affects trust of other people's cross-signing // signatures const trustCrossSig = this._trustCrossSignedDevices || userId === this._userId; return this._crossSigningInfo.checkDeviceTrust( userCrossSigning, device, trustedLocally, trustCrossSig, ); } else { return new DeviceTrustLevel(false, false, trustedLocally, false); } }; /* * Event handler for DeviceList's userNewDevices event */ Crypto.prototype._onDeviceListUserCrossSigningUpdated = async function(userId) { if (userId === this._userId) { // An update to our own cross-signing key. // Get the new key first: const newCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); const seenPubkey = newCrossSigning ? newCrossSigning.getId() : null; const currentPubkey = this._crossSigningInfo.getId(); const changed = currentPubkey !== seenPubkey; if (currentPubkey && seenPubkey && !changed) { // If it's not changed, just make sure everything is up to date await this.checkOwnCrossSigningTrust(); } else { // We'll now be in a state where cross-signing on the account is not trusted // because our locally stored cross-signing keys will not match the ones // on the server for our account. So we clear our own stored cross-signing keys, // effectively disabling cross-signing until the user gets verified by the device // that reset the keys this._storeTrustedSelfKeys(null); // emit cross-signing has been disabled this.emit("crossSigning.keysChanged", {}); // as the trust for our own user has changed, // also emit an event for this this.emit("userTrustStatusChanged", this._userId, this.checkUserTrust(userId)); } } else { await this._checkDeviceVerifications(userId); // Update verified before latch using the current state and save the new // latch value in the device list store. const crossSigning = this._deviceList.getStoredCrossSigningForUser(userId); if (crossSigning) { crossSigning.updateCrossSigningVerifiedBefore( this.checkUserTrust(userId).isCrossSigningVerified(), ); this._deviceList.setRawStoredCrossSigningForUser( userId, crossSigning.toStorage(), ); } this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); } }; /** * Check the copy of our cross-signing key that we have in the device list and * see if we can get the private key. If so, mark it as trusted. */ Crypto.prototype.checkOwnCrossSigningTrust = async function({ allowPrivateKeyRequests = false, } = {}) { const userId = this._userId; // Before proceeding, ensure our cross-signing public keys have been // downloaded via the device list. await this.downloadKeys([this._userId]); // Also check which private keys are locally cached. const crossSigningPrivateKeys = await this._crossSigningInfo.getCrossSigningKeysFromCache(); // If we see an update to our own master key, check it against the master // key we have and, if it matches, mark it as verified // First, get the new cross-signing info const newCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); if (!newCrossSigning) { logger.error( "Got cross-signing update event for user " + userId + " but no new cross-signing information found!", ); return; } const seenPubkey = newCrossSigning.getId(); const masterChanged = this._crossSigningInfo.getId() !== seenPubkey; const masterExistsNotLocallyCached = newCrossSigning.getId() && !crossSigningPrivateKeys.has("master"); if (masterChanged) { logger.info("Got new master public key", seenPubkey); } if ( allowPrivateKeyRequests && (masterChanged || masterExistsNotLocallyCached) ) { logger.info("Attempting to retrieve cross-signing master private key"); let signing = null; // It's important for control flow that we leave any errors alone for // higher levels to handle so that e.g. cancelling access properly // aborts any larger operation as well. try { const ret = await this._crossSigningInfo.getCrossSigningKey( 'master', seenPubkey, ); signing = ret[1]; logger.info("Got cross-signing master private key"); } finally { if (signing) signing.free(); } } const oldSelfSigningId = this._crossSigningInfo.getId("self_signing"); const oldUserSigningId = this._crossSigningInfo.getId("user_signing"); // Update the version of our keys in our cross-signing object and the local store this._storeTrustedSelfKeys(newCrossSigning.keys); const selfSigningChanged = oldSelfSigningId !== newCrossSigning.getId("self_signing"); const userSigningChanged = oldUserSigningId !== newCrossSigning.getId("user_signing"); const selfSigningExistsNotLocallyCached = ( newCrossSigning.getId("self_signing") && !crossSigningPrivateKeys.has("self_signing") ); const userSigningExistsNotLocallyCached = ( newCrossSigning.getId("user_signing") && !crossSigningPrivateKeys.has("user_signing") ); const keySignatures = {}; if (selfSigningChanged) { logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); } if ( allowPrivateKeyRequests && (selfSigningChanged || selfSigningExistsNotLocallyCached) ) { logger.info("Attempting to retrieve cross-signing self-signing private key"); let signing = null; try { const ret = await this._crossSigningInfo.getCrossSigningKey( "self_signing", newCrossSigning.getId("self_signing"), ); signing = ret[1]; logger.info("Got cross-signing self-signing private key"); } finally { if (signing) signing.free(); } const device = this._deviceList.getStoredDevice(this._userId, this._deviceId); const signedDevice = await this._crossSigningInfo.signDevice( this._userId, device, ); keySignatures[this._deviceId] = signedDevice; } if (userSigningChanged) { logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); } if ( allowPrivateKeyRequests && (userSigningChanged || userSigningExistsNotLocallyCached) ) { logger.info("Attempting to retrieve cross-signing user-signing private key"); let signing = null; try { const ret = await this._crossSigningInfo.getCrossSigningKey( "user_signing", newCrossSigning.getId("user_signing"), ); signing = ret[1]; logger.info("Got cross-signing user-signing private key"); } finally { if (signing) signing.free(); } } if (masterChanged) { const masterKey = this._crossSigningInfo.keys.master; await this._signObject(masterKey); const deviceSig = masterKey.signatures[this._userId]["ed25519:" + this._deviceId]; // Include only the _new_ device signature in the upload. // We may have existing signatures from deleted devices, which will cause // the entire upload to fail. keySignatures[this._crossSigningInfo.getId()] = Object.assign( {}, masterKey, { signatures: { [this._userId]: { ["ed25519:" + this._deviceId]: deviceSig, }, }, }, ); } const keysToUpload = Object.keys(keySignatures); if (keysToUpload.length) { const upload = ({ shouldEmit }) => { logger.info(`Starting background key sig upload for ${keysToUpload}`); return this._baseApis.uploadKeySignatures({ [this._userId]: keySignatures }) .then((response) => { const { failures } = response || {}; logger.info(`Finished background key sig upload for ${keysToUpload}`); if (Object.keys(failures || []).length > 0) { if (shouldEmit) { this._baseApis.emit( "crypto.keySignatureUploadFailure", failures, "checkOwnCrossSigningTrust", upload, ); } throw new KeySignatureUploadError("Key upload failed", { failures }); } }).catch(e => { logger.error( `Error during background key sig upload for ${keysToUpload}`, e, ); }); }; upload({ shouldEmit: true }); } this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); if (masterChanged) { this._baseApis.emit("crossSigning.keysChanged", {}); await this._afterCrossSigningLocalKeyChange(); } // Now we may be able to trust our key backup await this.checkKeyBackup(); // FIXME: if we previously trusted the backup, should we automatically sign // the backup with the new key (if not already signed)? }; /** * Store a set of keys as our own, trusted, cross-signing keys. * * @param {object} keys The new trusted set of keys */ Crypto.prototype._storeTrustedSelfKeys = async function(keys) { if (keys) { this._crossSigningInfo.setKeys(keys); } else { this._crossSigningInfo.clearKeys(); } await this._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { this._cryptoStore.storeCrossSigningKeys(txn, this._crossSigningInfo.keys); }, ); }; /** * Check if the master key is signed by a verified device, and if so, prompt * the application to mark it as verified. * * @param {string} userId the user ID whose key should be checked */ Crypto.prototype._checkDeviceVerifications = async function(userId) { const shouldUpgradeCb = ( this._baseApis._cryptoCallbacks.shouldUpgradeDeviceVerifications ); if (!shouldUpgradeCb) { // Upgrading skipped when callback is not present. return; } logger.info(`Starting device verification upgrade for ${userId}`); if (this._crossSigningInfo.keys.user_signing) { const crossSigningInfo = this._deviceList.getStoredCrossSigningForUser(userId); if (crossSigningInfo) { const upgradeInfo = await this._checkForDeviceVerificationUpgrade( userId, crossSigningInfo, ); if (upgradeInfo) { const usersToUpgrade = await shouldUpgradeCb({ users: { [userId]: upgradeInfo, }, }); if (usersToUpgrade.includes(userId)) { await this._baseApis.setDeviceVerified( userId, crossSigningInfo.getId(), ); } } } } logger.info(`Finished device verification upgrade for ${userId}`); }; /** * Check the server for an active key backup and * if one is present and has a valid signature from * one of the user's verified devices, start backing up * to it. */ Crypto.prototype._checkAndStartKeyBackup = async function() { logger.log("Checking key backup status..."); if (this._baseApis.isGuest()) { logger.log("Skipping key backup check since user is guest"); this._checkedForBackup = true; return null; } let backupInfo; try { backupInfo = await this._baseApis.getKeyBackupVersion(); } catch (e) { logger.log("Error checking for active key backup", e); if (e.httpStatus === 404) { // 404 is returned when the key backup does not exist, so that // counts as successfully checking. this._checkedForBackup = true; } return null; } this._checkedForBackup = true; const trustInfo = await this.isKeyBackupTrusted(backupInfo); if (trustInfo.usable && !this.backupInfo) { logger.log( "Found usable key backup v" + backupInfo.version + ": enabling key backups", ); this._baseApis.enableKeyBackup(backupInfo); } else if (!trustInfo.usable && this.backupInfo) { logger.log("No usable key backup: disabling key backup"); this._baseApis.disableKeyBackup(); } else if (!trustInfo.usable && !this.backupInfo) { logger.log("No usable key backup: not enabling key backup"); } else if (trustInfo.usable && this.backupInfo) { // may not be the same version: if not, we should switch if (backupInfo.version !== this.backupInfo.version) { logger.log( "On backup version " + this.backupInfo.version + " but found " + "version " + backupInfo.version + ": switching.", ); this._baseApis.disableKeyBackup(); this._baseApis.enableKeyBackup(backupInfo); // We're now using a new backup, so schedule all the keys we have to be // uploaded to the new backup. This is a bit of a workaround to upload // keys to a new backup in *most* cases, but it won't cover all cases // because we don't remember what backup version we uploaded keys to: // see https://github.com/vector-im/element-web/issues/14833 await this.scheduleAllGroupSessionsForBackup(); } else { logger.log("Backup version " + backupInfo.version + " still current"); } } return {backupInfo, trustInfo}; }; Crypto.prototype.setTrustedBackupPubKey = async function(trustedPubKey) { // This should be redundant post cross-signing is a thing, so just // plonk it in localStorage for now. this._sessionStore.setLocalTrustedBackupPubKey(trustedPubKey); await this.checkKeyBackup(); }; /** * Forces a re-check of the key backup and enables/disables it * as appropriate. * * @return {Object} Object with backup info (as returned by * getKeyBackupVersion) in backupInfo and * trust information (as returned by isKeyBackupTrusted) * in trustInfo. */ Crypto.prototype.checkKeyBackup = async function() { this._checkedForBackup = false; return this._checkAndStartKeyBackup(); }; /** * @param {object} backupInfo key backup info dict from /room_keys/version * @return {object} { * usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device * sigs: [ * valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation * deviceId: [string], * device: [DeviceInfo || null], * ] * } */ Crypto.prototype.isKeyBackupTrusted = async function(backupInfo) { const ret = { usable: false, trusted_locally: false, sigs: [], }; if ( !backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.public_key || !backupInfo.auth_data.signatures ) { logger.info("Key backup is absent or missing required data"); return ret; } const trustedPubkey = this._sessionStore.getLocalTrustedBackupPubKey(); if (backupInfo.auth_data.public_key === trustedPubkey) { logger.info("Backup public key " + trustedPubkey + " is trusted locally"); ret.trusted_locally = true; } const mySigs = backupInfo.auth_data.signatures[this._userId] || []; for (const keyId of Object.keys(mySigs)) { const keyIdParts = keyId.split(':'); if (keyIdParts[0] !== 'ed25519') { logger.log("Ignoring unknown signature type: " + keyIdParts[0]); continue; } // Could be a cross-signing master key, but just say this is the device // ID for backwards compat const sigInfo = { deviceId: keyIdParts[1] }; // first check to see if it's from our cross-signing key const crossSigningId = this._crossSigningInfo.getId(); if (crossSigningId === sigInfo.deviceId) { sigInfo.crossSigningId = true; try { await olmlib.verifySignature( this._olmDevice, backupInfo.auth_data, this._userId, sigInfo.deviceId, crossSigningId, ); sigInfo.valid = true; } catch (e) { logger.warning( "Bad signature from cross signing key " + crossSigningId, e, ); sigInfo.valid = false; } ret.sigs.push(sigInfo); continue; } // Now look for a sig from a device // At some point this can probably go away and we'll just support // it being signed by the cross-signing master key const device = this._deviceList.getStoredDevice( this._userId, sigInfo.deviceId, ); if (device) { sigInfo.device = device; sigInfo.deviceTrust = await this.checkDeviceTrust( this._userId, sigInfo.deviceId, ); try { await olmlib.verifySignature( this._olmDevice, backupInfo.auth_data, this._userId, device.deviceId, device.getFingerprint(), ); sigInfo.valid = true; } catch (e) { logger.info( "Bad signature from key ID " + keyId + " userID " + this._userId + " device ID " + device.deviceId + " fingerprint: " + device.getFingerprint(), backupInfo.auth_data, e, ); sigInfo.valid = false; } } else { sigInfo.valid = null; // Can't determine validity because we don't have the signing device logger.info("Ignoring signature from unknown key " + keyId); } ret.sigs.push(sigInfo); } ret.usable = ret.sigs.some((s) => { return ( s.valid && ( (s.device && s.deviceTrust.isVerified()) || (s.crossSigningId) ) ); }); ret.usable |= ret.trusted_locally; return ret; }; /** */ Crypto.prototype.enableLazyLoading = function() { this._lazyLoadMembers = true; }; /** * Tell the crypto module to register for MatrixClient events which it needs to * listen for * * @param {external:EventEmitter} eventEmitter event source where we can register * for event notifications */ Crypto.prototype.registerEventHandlers = function(eventEmitter) { const crypto = this; eventEmitter.on("RoomMember.membership", function(event, member, oldMembership) { try { crypto._onRoomMembership(event, member, oldMembership); } catch (e) { logger.error("Error handling membership change:", e); } }); eventEmitter.on("toDeviceEvent", crypto._onToDeviceEvent.bind(crypto)); const timelineHandler = crypto._onTimelineEvent.bind(crypto); eventEmitter.on("Room.timeline", timelineHandler); eventEmitter.on("Event.decrypted", timelineHandler); }; /** Start background processes related to crypto */ Crypto.prototype.start = function() { this._outgoingRoomKeyRequestManager.start(); }; /** Stop background processes related to crypto */ Crypto.prototype.stop = function() { this._outgoingRoomKeyRequestManager.stop(); this._deviceList.stop(); this._dehydrationManager.stop(); }; /** * @return {string} The version of Olm. */ Crypto.getOlmVersion = function() { return OlmDevice.getOlmVersion(); }; /** * Get the Ed25519 key for this device * * @return {string} base64-encoded ed25519 key. */ Crypto.prototype.getDeviceEd25519Key = function() { return this._olmDevice.deviceEd25519Key; }; /** * Get the Curve25519 key for this device * * @return {string} base64-encoded curve25519 key. */ Crypto.prototype.getDeviceCurve25519Key = function() { return this._olmDevice.deviceCurve25519Key; }; /** * Set the global override for whether the client should ever send encrypted * messages to unverified devices. This provides the default for rooms which * do not specify a value. * * @param {boolean} value whether to blacklist all unverified devices by default */ Crypto.prototype.setGlobalBlacklistUnverifiedDevices = function(value) { this._globalBlacklistUnverifiedDevices = value; }; /** * @return {boolean} whether to blacklist all unverified devices by default */ Crypto.prototype.getGlobalBlacklistUnverifiedDevices = function() { return this._globalBlacklistUnverifiedDevices; }; /** * Set whether sendMessage in a room with unknown and unverified devices * should throw an error and not send them message. This has 'Global' for * symmertry with setGlobalBlacklistUnverifiedDevices but there is currently * no room-level equivalent for this setting. * * This API is currently UNSTABLE and may change or be removed without notice. * * @param {boolean} value whether error on unknown devices */ Crypto.prototype.setGlobalErrorOnUnknownDevices = function(value) { this._globalErrorOnUnknownDevices = value; }; /** * @return {boolean} whether to error on unknown devices * * This API is currently UNSTABLE and may change or be removed without notice. */ Crypto.prototype.getGlobalErrorOnUnknownDevices = function() { return this._globalErrorOnUnknownDevices; }; /** * Upload the device keys to the homeserver. * @return {object} A promise that will resolve when the keys are uploaded. */ Crypto.prototype.uploadDeviceKeys = function() { const crypto = this; const userId = crypto._userId; const deviceId = crypto._deviceId; const deviceKeys = { algorithms: crypto._supportedAlgorithms, device_id: deviceId, keys: crypto._deviceKeys, user_id: userId, }; return crypto._signObject(deviceKeys).then(() => { return crypto._baseApis.uploadKeysRequest({ device_keys: deviceKeys, }); }); }; /** * Stores the current one_time_key count which will be handled later (in a call of * onSyncCompleted). The count is e.g. coming from a /sync response. * * @param {Number} currentCount The current count of one_time_keys to be stored */ Crypto.prototype.updateOneTimeKeyCount = function(currentCount) { if (isFinite(currentCount)) { this._oneTimeKeyCount = currentCount; } else { throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number"); } }; Crypto.prototype.setNeedsNewFallback = function(needsNewFallback) { this._needsNewFallback = !!needsNewFallback; }; Crypto.prototype.getNeedsNewFallback = function() { return this._needsNewFallback; }; // check if it's time to upload one-time keys, and do so if so. function _maybeUploadOneTimeKeys(crypto) { // frequency with which to check & upload one-time keys const uploadPeriod = 1000 * 60; // one minute // max number of keys to upload at once // Creating keys can be an expensive operation so we limit the // number we generate in one go to avoid blocking the application // for too long. const maxKeysPerCycle = 5; if (crypto._oneTimeKeyCheckInProgress) { return; } const now = Date.now(); if (crypto._lastOneTimeKeyCheck !== null && now - crypto._lastOneTimeKeyCheck < uploadPeriod ) { // we've done a key upload recently. return; } crypto._lastOneTimeKeyCheck = now; // We need to keep a pool of one time public keys on the server so that // other devices can start conversations with us. But we can only store // a finite number of private keys in the olm Account object. // To complicate things further then can be a delay between a device // claiming a public one time key from the server and it sending us a // message. We need to keep the corresponding private key locally until // we receive the message. // But that message might never arrive leaving us stuck with duff // private keys clogging up our local storage. // So we need some kind of enginering compromise to balance all of // these factors. // Check how many keys we can store in the Account object. const maxOneTimeKeys = crypto._olmDevice.maxNumberOfOneTimeKeys(); // Try to keep at most half that number on the server. This leaves the // rest of the slots free to hold keys that have been claimed from the // server but we haven't recevied a message for. // If we run out of slots when generating new keys then olm will // discard the oldest private keys first. This will eventually clean // out stale private keys that won't receive a message. const keyLimit = Math.floor(maxOneTimeKeys / 2); async function uploadLoop(keyCount) { while (keyLimit > keyCount || crypto.getNeedsNewFallback()) { // Ask olm to generate new one time keys, then upload them to synapse. if (keyLimit > keyCount) { logger.info("generating oneTimeKeys"); const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle); await crypto._olmDevice.generateOneTimeKeys(keysThisLoop); } if (crypto.getNeedsNewFallback()) { logger.info("generating fallback key"); await crypto._olmDevice.generateFallbackKey(); } logger.info("calling _uploadOneTimeKeys"); const res = await _uploadOneTimeKeys(crypto); if (res.one_time_key_counts && res.one_time_key_counts.signed_curve25519) { // if the response contains a more up to date value use this // for the next loop keyCount = res.one_time_key_counts.signed_curve25519; } else { throw new Error("response for uploading keys does not contain " + "one_time_key_counts.signed_curve25519"); } } } crypto._oneTimeKeyCheckInProgress = true; Promise.resolve().then(() => { if (crypto._oneTimeKeyCount !== undefined) { // We already have the current one_time_key count from a /sync response. // Use this value instead of asking the server for the current key count. return Promise.resolve(crypto._oneTimeKeyCount); } // ask the server how many keys we have return crypto._baseApis.uploadKeysRequest({}).then((res) => { return res.one_time_key_counts.signed_curve25519 || 0; }); }).then((keyCount) => { // Start the uploadLoop with the current keyCount. The function checks if // we need to upload new keys or not. // If there are too many keys on the server then we don't need to // create any more keys. return uploadLoop(keyCount); }).catch((e) => { logger.error("Error uploading one-time keys", e.stack || e); }).finally(() => { // reset _oneTimeKeyCount to prevent start uploading based on old data. // it will be set again on the next /sync-response crypto._oneTimeKeyCount = undefined; crypto._oneTimeKeyCheckInProgress = false; }); } // returns a promise which resolves to the response async function _uploadOneTimeKeys(crypto) { const promises = []; const fallbackJson = {}; if (crypto.getNeedsNewFallback()) { const fallbackKeys = await crypto._olmDevice.getFallbackKey(); for (const [keyId, key] of Object.entries(fallbackKeys.curve25519)) { const k = { key, fallback: true }; fallbackJson["signed_curve25519:" + keyId] = k; promises.push(crypto._signObject(k)); } crypto.setNeedsNewFallback(false); } const oneTimeKeys = await crypto._olmDevice.getOneTimeKeys(); const oneTimeJson = {}; for (const keyId in oneTimeKeys.curve25519) { if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { const k = { key: oneTimeKeys.curve25519[keyId], }; oneTimeJson["signed_curve25519:" + keyId] = k; promises.push(crypto._signObject(k)); } } await Promise.all(promises); const res = await crypto._baseApis.uploadKeysRequest({ "one_time_keys": oneTimeJson, "org.matrix.msc2732.fallback_keys": fallbackJson, }); await crypto._olmDevice.markKeysAsPublished(); return res; } /** * Download the keys for a list of users and stores the keys in the session * store. * @param {Array} userIds The users to fetch. * @param {bool} forceDownload Always download the keys even if cached. * * @return {Promise} A promise which resolves to a map userId->deviceId->{@link * module:crypto/deviceinfo|DeviceInfo}. */ Crypto.prototype.downloadKeys = function(userIds, forceDownload) { return this._deviceList.downloadKeys(userIds, forceDownload); }; /** * Get the stored device keys for a user id * * @param {string} userId the user to list keys for. * * @return {module:crypto/deviceinfo[]|null} list of devices, or null if we haven't * managed to get a list of devices for this user yet. */ Crypto.prototype.getStoredDevicesForUser = function(userId) { return this._deviceList.getStoredDevicesForUser(userId); }; /** * Get the stored keys for a single device * * @param {string} userId * @param {string} deviceId * * @return {module:crypto/deviceinfo?} device, or undefined * if we don't know about this device */ Crypto.prototype.getStoredDevice = function(userId, deviceId) { return this._deviceList.getStoredDevice(userId, deviceId); }; /** * Save the device list, if necessary * * @param {integer} delay Time in ms before which the save actually happens. * By default, the save is delayed for a short period in order to batch * multiple writes, but this behaviour can be disabled by passing 0. * * @return {Promise} true if the data was saved, false if * it was not (eg. because no changes were pending). The promise * will only resolve once the data is saved, so may take some time * to resolve. */ Crypto.prototype.saveDeviceList = function(delay) { return this._deviceList.saveIfDirty(delay); }; /** * Update the blocked/verified state of the given device * * @param {string} userId owner of the device * @param {string} deviceId unique identifier for the device or user's * cross-signing public key ID. * * @param {?boolean} verified whether to mark the device as verified. Null to * leave unchanged. * * @param {?boolean} blocked whether to mark the device as blocked. Null to * leave unchanged. * * @param {?boolean} known whether to mark that the user has been made aware of * the existence of this device. Null to leave unchanged * * @return {Promise} updated DeviceInfo */ Crypto.prototype.setDeviceVerification = async function( userId, deviceId, verified, blocked, known, ) { // get rid of any `undefined`s here so we can just check // for null rather than null or undefined if (verified === undefined) verified = null; if (blocked === undefined) blocked = null; if (known === undefined) known = null; // Check if the 'device' is actually a cross signing key // The js-sdk's verification treats cross-signing keys as devices // and so uses this method to mark them verified. const xsk = this._deviceList.getStoredCrossSigningForUser(userId); if (xsk && xsk.getId() === deviceId) { if (blocked !== null || known !== null) { throw new Error("Cannot set blocked or known for a cross-signing key"); } if (!verified) { throw new Error("Cannot set a cross-signing key as unverified"); } if (!this._crossSigningInfo.getId() && userId === this._crossSigningInfo.userId) { this._storeTrustedSelfKeys(xsk.keys); // This will cause our own user trust to change, so emit the event this.emit( "userTrustStatusChanged", this._userId, this.checkUserTrust(userId), ); } // Now sign the master key with our user signing key (unless it's ourself) if (userId !== this._userId) { logger.info( "Master key " + xsk.getId() + " for " + userId + " marked verified. Signing...", ); const device = await this._crossSigningInfo.signUser(xsk); if (device) { const upload = async ({ shouldEmit }) => { logger.info("Uploading signature for " + userId + "..."); const response = await this._baseApis.uploadKeySignatures({ [userId]: { [deviceId]: device, }, }); const { failures } = response || {}; if (Object.keys(failures || []).length > 0) { if (shouldEmit) { this._baseApis.emit( "crypto.keySignatureUploadFailure", failures, "setDeviceVerification", upload, ); } /* Throwing here causes the process to be cancelled and the other * user to be notified */ throw new KeySignatureUploadError( "Key upload failed", { failures }, ); } }; await upload({ shouldEmit: true }); // This will emit events when it comes back down the sync // (we could do local echo to speed things up) } return device; } else { return xsk; } } const devices = this._deviceList.getRawStoredDevicesForUser(userId); if (!devices || !devices[deviceId]) { throw new Error("Unknown device " + userId + ":" + deviceId); } const dev = devices[deviceId]; let verificationStatus = dev.verified; if (verified) { verificationStatus = DeviceVerification.VERIFIED; } else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) { verificationStatus = DeviceVerification.UNVERIFIED; } if (blocked) { verificationStatus = DeviceVerification.BLOCKED; } else if (blocked !== null && verificationStatus == DeviceVerification.BLOCKED) { verificationStatus = DeviceVerification.UNVERIFIED; } let knownStatus = dev.known; if (known !== null) { knownStatus = known; } if (dev.verified !== verificationStatus || dev.known !== knownStatus) { dev.verified = verificationStatus; dev.known = knownStatus; this._deviceList.storeDevicesForUser(userId, devices); this._deviceList.saveIfDirty(); } // do cross-signing if (verified && userId === this._userId) { logger.info("Own device " + deviceId + " marked verified: signing"); // Signing only needed if other device not already signed let device; const deviceTrust = this.checkDeviceTrust(userId, deviceId); if (deviceTrust.isCrossSigningVerified()) { logger.log(`Own device ${deviceId} already cross-signing verified`); } else { device = await this._crossSigningInfo.signDevice( userId, DeviceInfo.fromStorage(dev, deviceId), ); } if (device) { const upload = async ({shouldEmit}) => { logger.info("Uploading signature for " + deviceId); const response = await this._baseApis.uploadKeySignatures({ [userId]: { [deviceId]: device, }, }); const { failures } = response || {}; if (Object.keys(failures || []).length > 0) { if (shouldEmit) { this._baseApis.emit( "crypto.keySignatureUploadFailure", failures, "setDeviceVerification", upload, // continuation ); } throw new KeySignatureUploadError("Key upload failed", { failures }); } }; await upload({shouldEmit: true}); // XXX: we'll need to wait for the device list to be updated } } const deviceObj = DeviceInfo.fromStorage(dev, deviceId); this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); return deviceObj; }; Crypto.prototype.findVerificationRequestDMInProgress = function(roomId) { return this._inRoomVerificationRequests.findRequestInProgress(roomId); }; Crypto.prototype.getVerificationRequestsToDeviceInProgress = function(userId) { return this._toDeviceVerificationRequests.getRequestsInProgress(userId); }; Crypto.prototype.requestVerificationDM = function(userId, roomId) { const existingRequest = this._inRoomVerificationRequests. findRequestInProgress(roomId); if (existingRequest) { return Promise.resolve(existingRequest); } const channel = new InRoomChannel(this._baseApis, roomId, userId); return this._requestVerificationWithChannel( userId, channel, this._inRoomVerificationRequests, ); }; Crypto.prototype.requestVerification = function(userId, devices) { if (!devices) { devices = Object.keys(this._deviceList.getRawStoredDevicesForUser(userId)); } const existingRequest = this._toDeviceVerificationRequests .findRequestInProgress(userId, devices); if (existingRequest) { return Promise.resolve(existingRequest); } const channel = new ToDeviceChannel(this._baseApis, userId, devices, ToDeviceChannel.makeTransactionId()); return this._requestVerificationWithChannel( userId, channel, this._toDeviceVerificationRequests, ); }; Crypto.prototype._requestVerificationWithChannel = async function( userId, channel, requestsMap, ) { let request = new VerificationRequest( channel, this._verificationMethods, this._baseApis); // if transaction id is already known, add request if (channel.transactionId) { requestsMap.setRequestByChannel(channel, request); } await request.sendRequest(); // don't replace the request created by a racing remote echo const racingRequest = requestsMap.getRequestByChannel(channel); if (racingRequest) { request = racingRequest; } else { logger.log(`Crypto: adding new request to ` + `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`); requestsMap.setRequestByChannel(channel, request); } return request; }; Crypto.prototype.beginKeyVerification = function( method, userId, deviceId, transactionId = null, ) { let request; if (transactionId) { request = this._toDeviceVerificationRequests.getRequestBySenderAndTxnId( userId, transactionId); if (!request) { throw new Error( `No request found for user ${userId} with ` + `transactionId ${transactionId}`); } } else { transactionId = ToDeviceChannel.makeTransactionId(); const channel = new ToDeviceChannel( this._baseApis, userId, [deviceId], transactionId, deviceId); request = new VerificationRequest( channel, this._verificationMethods, this._baseApis); this._toDeviceVerificationRequests.setRequestBySenderAndTxnId( userId, transactionId, request); } return request.beginKeyVerification(method, {userId, deviceId}); }; Crypto.prototype.legacyDeviceVerification = async function( userId, deviceId, method, ) { const transactionId = ToDeviceChannel.makeTransactionId(); const channel = new ToDeviceChannel( this._baseApis, userId, [deviceId], transactionId, deviceId); const request = new VerificationRequest( channel, this._verificationMethods, this._baseApis); this._toDeviceVerificationRequests.setRequestBySenderAndTxnId( userId, transactionId, request); const verifier = request.beginKeyVerification(method, {userId, deviceId}); // either reject by an error from verify() while sending .start // or resolve when the request receives the // local (fake remote) echo for sending the .start event await Promise.race([ verifier.verify(), request.waitFor(r => r.started), ]); return request; }; /** * Get information on the active olm sessions with a user *

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

    * This method is provided for debugging purposes. * * @param {string} userId id of user to inspect * * @return {Promise>} */ Crypto.prototype.getOlmSessionsForUser = async function(userId) { const devices = this.getStoredDevicesForUser(userId) || []; const result = {}; for (let j = 0; j < devices.length; ++j) { const device = devices[j]; const deviceKey = device.getIdentityKey(); const sessions = await this._olmDevice.getSessionInfoForDevice(deviceKey); result[device.deviceId] = { deviceIdKey: deviceKey, sessions: sessions, }; } return result; }; /** * Get the device which sent an event * * @param {module:models/event.MatrixEvent} event event to be checked * * @return {module:crypto/deviceinfo?} */ Crypto.prototype.getEventSenderDeviceInfo = function(event) { const senderKey = event.getSenderKey(); const algorithm = event.getWireContent().algorithm; if (!senderKey || !algorithm) { return null; } const forwardingChain = event.getForwardingCurve25519KeyChain(); if (forwardingChain.length > 0) { // we got the key this event from somewhere else // TODO: check if we can trust the forwarders. return null; } if (event.isKeySourceUntrusted()) { // we got the key for this event from a source that we consider untrusted return null; } // senderKey is the Curve25519 identity key of the device which the event // was sent from. In the case of Megolm, it's actually the Curve25519 // identity key of the device which set up the Megolm session. const device = this._deviceList.getDeviceByIdentityKey( algorithm, senderKey, ); if (device === null) { // we haven't downloaded the details of this device yet. return null; } // so far so good, but now we need to check that the sender of this event // hadn't advertised someone else's Curve25519 key as their own. We do that // by checking the Ed25519 claimed by the event (or, in the case of megolm, // the event which set up the megolm session), to check that it matches the // fingerprint of the purported sending device. // // (see https://github.com/vector-im/vector-web/issues/2215) const claimedKey = event.getClaimedEd25519Key(); if (!claimedKey) { logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); return null; } if (claimedKey !== device.getFingerprint()) { logger.warn( "Event " + event.getId() + " claims ed25519 key " + claimedKey + " but sender device has key " + device.getFingerprint()); return null; } return device; }; /** * Get information about the encryption of an event * * @param {module:models/event.MatrixEvent} event event to be checked * * @return {object} An object with the fields: * - encrypted: whether the event is encrypted (if not encrypted, some of the * other properties may not be set) * - senderKey: the sender's key * - algorithm: the algorithm used to encrypt the event * - authenticated: whether we can be sure that the owner of the senderKey * sent the event * - sender: the sender's device information, if available * - mismatchedSender: if the event's ed25519 and curve25519 keys don't match * (only meaningful if `sender` is set) */ Crypto.prototype.getEventEncryptionInfo = function(event) { const ret = {}; ret.senderKey = event.getSenderKey(); ret.algorithm = event.getWireContent().algorithm; if (!ret.senderKey || !ret.algorithm) { ret.encrypted = false; return ret; } ret.encrypted = true; const forwardingChain = event.getForwardingCurve25519KeyChain(); if (forwardingChain.length > 0 || event.isKeySourceUntrusted()) { // we got the key this event from somewhere else // TODO: check if we can trust the forwarders. ret.authenticated = false; } else { ret.authenticated = true; } // senderKey is the Curve25519 identity key of the device which the event // was sent from. In the case of Megolm, it's actually the Curve25519 // identity key of the device which set up the Megolm session. ret.sender = this._deviceList.getDeviceByIdentityKey( ret.algorithm, ret.senderKey, ); // so far so good, but now we need to check that the sender of this event // hadn't advertised someone else's Curve25519 key as their own. We do that // by checking the Ed25519 claimed by the event (or, in the case of megolm, // the event which set up the megolm session), to check that it matches the // fingerprint of the purported sending device. // // (see https://github.com/vector-im/vector-web/issues/2215) const claimedKey = event.getClaimedEd25519Key(); if (!claimedKey) { logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); ret.mismatchedSender = true; } if (ret.sender && claimedKey !== ret.sender.getFingerprint()) { logger.warn( "Event " + event.getId() + " claims ed25519 key " + claimedKey + "but sender device has key " + ret.sender.getFingerprint()); ret.mismatchedSender = true; } return ret; }; /** * Forces the current outbound group session to be discarded such * that another one will be created next time an event is sent. * * @param {string} roomId The ID of the room to discard the session for * * This should not normally be necessary. */ Crypto.prototype.forceDiscardSession = function(roomId) { const alg = this._roomEncryptors[roomId]; if (alg === undefined) throw new Error("Room not encrypted"); if (alg.forceDiscardSession === undefined) { throw new Error("Room encryption algorithm doesn't support session discarding"); } alg.forceDiscardSession(); }; /** * Configure a room to use encryption (ie, save a flag in the cryptoStore). * * @param {string} roomId The room ID to enable encryption in. * * @param {object} config The encryption config for the room. * * @param {boolean=} inhibitDeviceQuery true to suppress device list query for * users in the room (for now). In case lazy loading is enabled, * the device query is always inhibited as the members are not tracked. */ Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDeviceQuery) { // ignore crypto events with no algorithm defined // This will happen if a crypto event is redacted before we fetch the room state // It would otherwise just throw later as an unknown algorithm would, but we may // as well catch this here if (!config.algorithm) { logger.log("Ignoring setRoomEncryption with no algorithm"); return; } // if state is being replayed from storage, we might already have a configuration // for this room as they are persisted as well. // We just need to make sure the algorithm is initialized in this case. // However, if the new config is different, // we should bail out as room encryption can't be changed once set. const existingConfig = this._roomList.getRoomEncryption(roomId); if (existingConfig) { if (JSON.stringify(existingConfig) != JSON.stringify(config)) { logger.error("Ignoring m.room.encryption event which requests " + "a change of config in " + roomId); return; } } // if we already have encryption in this room, we should ignore this event, // as it would reset the encryption algorithm. // This is at least expected to be called twice, as sync calls onCryptoEvent // for both the timeline and state sections in the /sync response, // the encryption event would appear in both. // If it's called more than twice though, // it signals a bug on client or server. const existingAlg = this._roomEncryptors[roomId]; if (existingAlg) { return; } // _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption // because it first stores in memory. We should await the promise only // after all the in-memory state (_roomEncryptors and _roomList) has been updated // to avoid races when calling this method multiple times. Hence keep a hold of the promise. let storeConfigPromise = null; if (!existingConfig) { storeConfigPromise = this._roomList.setRoomEncryption(roomId, config); } const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm]; if (!AlgClass) { throw new Error("Unable to encrypt with " + config.algorithm); } const alg = new AlgClass({ userId: this._userId, deviceId: this._deviceId, crypto: this, olmDevice: this._olmDevice, baseApis: this._baseApis, roomId: roomId, config: config, }); this._roomEncryptors[roomId] = alg; if (storeConfigPromise) { await storeConfigPromise; } if (!this._lazyLoadMembers) { logger.log("Enabling encryption in " + roomId + "; " + "starting to track device lists for all users therein"); await this.trackRoomDevices(roomId); // TODO: this flag is only not used from MatrixClient::setRoomEncryption // which is never used (inside Element at least) // but didn't want to remove it as it technically would // be a breaking change. if (!this.inhibitDeviceQuery) { this._deviceList.refreshOutdatedDeviceLists(); } } else { logger.log("Enabling encryption in " + roomId); } }; /** * Make sure we are tracking the device lists for all users in this room. * * @param {string} roomId The room ID to start tracking devices in. * @returns {Promise} when all devices for the room have been fetched and marked to track */ Crypto.prototype.trackRoomDevices = function(roomId) { const trackMembers = async () => { // not an encrypted room if (!this._roomEncryptors[roomId]) { return; } const room = this._clientStore.getRoom(roomId); if (!room) { throw new Error(`Unable to start tracking devices in unknown room ${roomId}`); } logger.log(`Starting to track devices for room ${roomId} ...`); const members = await room.getEncryptionTargetMembers(); members.forEach((m) => { this._deviceList.startTrackingDeviceList(m.userId); }); }; let promise = this._roomDeviceTrackingState[roomId]; if (!promise) { promise = trackMembers(); this._roomDeviceTrackingState[roomId] = promise.catch(err => { this._roomDeviceTrackingState[roomId] = null; throw err; }); } return promise; }; /** * @typedef {Object} module:crypto~OlmSessionResult * @property {module:crypto/deviceinfo} device device info * @property {string?} sessionId base64 olm session id; null if no session * could be established */ /** * Try to make sure we have established olm sessions for all known devices for * the given users. * * @param {string[]} users list of user ids * * @return {Promise} resolves once the sessions are complete, to * an Object mapping from userId to deviceId to * {@link module:crypto~OlmSessionResult} */ Crypto.prototype.ensureOlmSessionsForUsers = function(users) { const devicesByUser = {}; for (let i = 0; i < users.length; ++i) { const userId = users[i]; devicesByUser[userId] = []; const devices = this.getStoredDevicesForUser(userId) || []; for (let j = 0; j < devices.length; ++j) { const deviceInfo = devices[j]; const key = deviceInfo.getIdentityKey(); if (key == this._olmDevice.deviceCurve25519Key) { // don't bother setting up session to ourself continue; } if (deviceInfo.verified == DeviceVerification.BLOCKED) { // don't bother setting up sessions with blocked users continue; } devicesByUser[userId].push(deviceInfo); } } return olmlib.ensureOlmSessionsForDevices( this._olmDevice, this._baseApis, devicesByUser, ); }; /** * Get a list containing all of the room keys * * @return {module:crypto/OlmDevice.MegolmSessionData[]} a list of session export objects */ Crypto.prototype.exportRoomKeys = async function() { const exportedSessions = []; await this._cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => { if (s === null) return; const sess = this._olmDevice.exportInboundGroupSession( s.senderKey, s.sessionId, s.sessionData, ); delete sess.first_known_index; sess.algorithm = olmlib.MEGOLM_ALGORITHM; exportedSessions.push(sess); }); }, ); return exportedSessions; }; /** * Import a list of room keys previously exported by exportRoomKeys * * @param {Object[]} keys a list of session export objects * @param {Object} opts * @param {Function} opts.progressCallback called with an object which has a stage param * @return {Promise} a promise which resolves once the keys have been imported */ Crypto.prototype.importRoomKeys = function(keys, opts = {}) { let successes = 0; let failures = 0; const total = keys.length; function updateProgress() { opts.progressCallback({ stage: "load_keys", successes, failures, total, }); } return Promise.all(keys.map((key) => { if (!key.room_id || !key.algorithm) { logger.warn("ignoring room key entry with missing fields", key); failures++; if (opts.progressCallback) { updateProgress(); } return null; } const alg = this._getRoomDecryptor(key.room_id, key.algorithm); return alg.importRoomKey(key, opts).finally((r) => { successes++; if (opts.progressCallback) { updateProgress(); } }); })); }; /** * Schedules sending all keys waiting to be sent to the backup, if not already * scheduled. Retries if necessary. * * @param {number} maxDelay Maximum delay to wait in ms. 0 means no delay. */ Crypto.prototype.scheduleKeyBackupSend = async function(maxDelay = 10000) { if (this._sendingBackups) return; this._sendingBackups = true; try { // wait between 0 and `maxDelay` seconds, to avoid backup // requests from different clients hitting the server all at // the same time when a new key is sent const delay = Math.random() * maxDelay; await sleep(delay); let numFailures = 0; // number of consecutive failures while (1) { if (!this.backupKey) { return; } try { const numBackedUp = await this._backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST); if (numBackedUp === 0) { // no sessions left needing backup: we're done return; } numFailures = 0; } catch (err) { numFailures++; logger.log("Key backup request failed", err); if (err.data) { if ( err.data.errcode == 'M_NOT_FOUND' || err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION' ) { // Re-check key backup status on error, so we can be // sure to present the current situation when asked. await this.checkKeyBackup(); // Backup version has changed or this backup version // has been deleted this.emit("crypto.keyBackupFailed", err.data.errcode); throw err; } } } if (numFailures) { // exponential backoff if we have failures await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4))); } } } finally { this._sendingBackups = false; } }; /** * Take some e2e keys waiting to be backed up and send them * to the backup. * * @param {integer} limit Maximum number of keys to back up * @returns {integer} Number of sessions backed up */ Crypto.prototype._backupPendingKeys = async function(limit) { const sessions = await this._cryptoStore.getSessionsNeedingBackup(limit); if (!sessions.length) { return 0; } let remaining = await this._cryptoStore.countSessionsNeedingBackup(); this.emit("crypto.keyBackupSessionsRemaining", remaining); const data = {}; for (const session of sessions) { const roomId = session.sessionData.room_id; if (data[roomId] === undefined) { data[roomId] = {sessions: {}}; } const sessionData = await this._olmDevice.exportInboundGroupSession( session.senderKey, session.sessionId, session.sessionData, ); sessionData.algorithm = olmlib.MEGOLM_ALGORITHM; delete sessionData.session_id; delete sessionData.room_id; const firstKnownIndex = sessionData.first_known_index; delete sessionData.first_known_index; const encrypted = this.backupKey.encrypt(JSON.stringify(sessionData)); const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length; const userId = this._deviceList.getUserByIdentityKey( olmlib.MEGOLM_ALGORITHM, session.senderKey, ); const device = this._deviceList.getDeviceByIdentityKey( olmlib.MEGOLM_ALGORITHM, session.senderKey, ); const verified = this._checkDeviceInfoTrust(userId, device).isVerified(); data[roomId]['sessions'][session.sessionId] = { first_message_index: firstKnownIndex, forwarded_count: forwardedCount, is_verified: verified, session_data: encrypted, }; } await this._baseApis.sendKeyBackup( undefined, undefined, this.backupInfo.version, {rooms: data}, ); await this._cryptoStore.unmarkSessionsNeedingBackup(sessions); remaining = await this._cryptoStore.countSessionsNeedingBackup(); this.emit("crypto.keyBackupSessionsRemaining", remaining); return sessions.length; }; Crypto.prototype.backupGroupSession = async function( roomId, senderKey, forwardingCurve25519KeyChain, sessionId, sessionKey, keysClaimed, exportFormat, ) { await this._cryptoStore.markSessionsNeedingBackup([{ senderKey: senderKey, sessionId: sessionId, }]); if (this.backupInfo) { // don't wait for this to complete: it will delay so // happens in the background this.scheduleKeyBackupSend(); } // if this.backupInfo is not set, then the keys will be backed up when // client.enableKeyBackup is called }; /** * Marks all group sessions as needing to be backed up and schedules them to * upload in the background as soon as possible. */ Crypto.prototype.scheduleAllGroupSessionsForBackup = async function() { await this.flagAllGroupSessionsForBackup(); // Schedule keys to upload in the background as soon as possible. this.scheduleKeyBackupSend(0 /* maxDelay */); }; /** * Marks all group sessions as needing to be backed up without scheduling * them to upload in the background. * @returns {Promise} Resolves to the number of sessions now requiring a backup * (which will be equal to the number of sessions in the store). */ Crypto.prototype.flagAllGroupSessionsForBackup = async function() { await this._cryptoStore.doTxn( 'readwrite', [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP, ], (txn) => { this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => { if (session !== null) { this._cryptoStore.markSessionsNeedingBackup([session], txn); } }); }, ); const remaining = await this._cryptoStore.countSessionsNeedingBackup(); this.emit("crypto.keyBackupSessionsRemaining", remaining); return remaining; }; /** * Counts the number of end to end session keys that are waiting to be backed up * @returns {Promise} Resolves to the number of sessions requiring backup */ Crypto.prototype.countSessionsNeedingBackup = function() { return this._cryptoStore.countSessionsNeedingBackup(); }; /** * Perform any background tasks that can be done before a message is ready to * send, in order to speed up sending of the message. * * @param {module:models/room} room the room the event is in */ Crypto.prototype.prepareToEncrypt = function(room) { const roomId = room.roomId; const alg = this._roomEncryptors[roomId]; if (alg) { alg.prepareToEncrypt(room); } }; /* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307 /** * Encrypt an event according to the configuration of the room. * * @param {module:models/event.MatrixEvent} event event to be sent * * @param {module:models/room} room destination room. * * @return {Promise?} Promise which resolves when the event has been * encrypted, or null if nothing was needed */ /* eslint-enable valid-jsdoc */ Crypto.prototype.encryptEvent = async function(event, room) { if (!room) { throw new Error("Cannot send encrypted messages in unknown rooms"); } const roomId = event.getRoomId(); const alg = this._roomEncryptors[roomId]; if (!alg) { // MatrixClient has already checked that this room should be encrypted, // so this is an unexpected situation. throw new Error( "Room was previously configured to use encryption, but is " + "no longer. Perhaps the homeserver is hiding the " + "configuration event.", ); } if (!this._roomDeviceTrackingState[roomId]) { this.trackRoomDevices(roomId); } // wait for all the room devices to be loaded await this._roomDeviceTrackingState[roomId]; let content = event.getContent(); // If event has an m.relates_to then we need // to put this on the wrapping event instead const mRelatesTo = content['m.relates_to']; if (mRelatesTo) { // Clone content here so we don't remove `m.relates_to` from the local-echo content = Object.assign({}, content); delete content['m.relates_to']; } const encryptedContent = await alg.encryptMessage( room, event.getType(), content); if (mRelatesTo) { encryptedContent['m.relates_to'] = mRelatesTo; } event.makeEncrypted( "m.room.encrypted", encryptedContent, this._olmDevice.deviceCurve25519Key, this._olmDevice.deviceEd25519Key, ); }; /** * Decrypt a received event * * @param {MatrixEvent} event * * @return {Promise} resolves once we have * finished decrypting. Rejects with an `algorithms.DecryptionError` if there * is a problem decrypting the event. */ Crypto.prototype.decryptEvent = async function(event) { if (event.isRedacted()) { const redactionEvent = new MatrixEvent(event.getUnsigned().redacted_because); const decryptedEvent = await this.decryptEvent(redactionEvent); return { clearEvent: { room_id: event.getRoomId(), type: "m.room.message", content: {}, unsigned: { redacted_because: decryptedEvent.clearEvent, }, }, }; } else { const content = event.getWireContent(); const alg = this._getRoomDecryptor(event.getRoomId(), content.algorithm); return await alg.decryptEvent(event); } }; /** * Handle the notification from /sync or /keys/changes that device lists have * been changed. * * @param {Object} syncData Object containing sync tokens associated with this sync * @param {Object} syncDeviceLists device_lists field from /sync, or response from * /keys/changes */ Crypto.prototype.handleDeviceListChanges = async function(syncData, syncDeviceLists) { // Initial syncs don't have device change lists. We'll either get the complete list // of changes for the interval or will have invalidated everything in willProcessSync if (!syncData.oldSyncToken) return; // Here, we're relying on the fact that we only ever save the sync data after // sucessfully saving the device list data, so we're guaranteed that the device // list store is at least as fresh as the sync token from the sync store, ie. // any device changes received in sync tokens prior to the 'next' token here // have been processed and are reflected in the current device list. // If we didn't make this assumption, we'd have to use the /keys/changes API // to get key changes between the sync token in the device list and the 'old' // sync token used here to make sure we didn't miss any. await this._evalDeviceListChanges(syncDeviceLists); }; /** * Send a request for some room keys, if we have not already done so * * @param {module:crypto~RoomKeyRequestBody} requestBody * @param {Array<{userId: string, deviceId: string}>} recipients * @param {boolean} resend whether to resend the key request if there is * already one * * @return {Promise} a promise that resolves when the key request is queued */ Crypto.prototype.requestRoomKey = function(requestBody, recipients, resend=false) { return this._outgoingRoomKeyRequestManager.queueRoomKeyRequest( requestBody, recipients, resend, ).then(() => { if (this._sendKeyRequestsImmediately) { this._outgoingRoomKeyRequestManager.sendQueuedRequests(); } }).catch((e) => { // this normally means we couldn't talk to the store logger.error( 'Error requesting key for event', e, ); }); }; /** * Cancel any earlier room key request * * @param {module:crypto~RoomKeyRequestBody} requestBody * parameters to match for cancellation */ Crypto.prototype.cancelRoomKeyRequest = function(requestBody) { this._outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody) .catch((e) => { logger.warn("Error clearing pending room key requests", e); }); }; /** * Re-send any outgoing key requests, eg after verification * @returns {Promise} */ Crypto.prototype.cancelAndResendAllOutgoingKeyRequests = function() { return this._outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); }; /** * handle an m.room.encryption event * * @param {module:models/event.MatrixEvent} event encryption event */ Crypto.prototype.onCryptoEvent = async function(event) { const roomId = event.getRoomId(); const content = event.getContent(); try { // inhibit the device list refresh for now - it will happen once we've // finished processing the sync, in onSyncCompleted. await this.setRoomEncryption(roomId, content, true); } catch (e) { logger.error("Error configuring encryption in room " + roomId + ":", e); } }; /** * Called before the result of a sync is procesed * * @param {Object} syncData the data from the 'MatrixClient.sync' event */ Crypto.prototype.onSyncWillProcess = async function(syncData) { if (!syncData.oldSyncToken) { // If there is no old sync token, we start all our tracking from // scratch, so mark everything as untracked. onCryptoEvent will // be called for all e2e rooms during the processing of the sync, // at which point we'll start tracking all the users of that room. logger.log("Initial sync performed - resetting device tracking state"); this._deviceList.stopTrackingAllDeviceLists(); // we always track our own device list (for key backups etc) this._deviceList.startTrackingDeviceList(this._userId); this._roomDeviceTrackingState = {}; } this._sendKeyRequestsImmediately = false; }; /** * handle the completion of a /sync * * This is called after the processing of each successful /sync response. * It is an opportunity to do a batch process on the information received. * * @param {Object} syncData the data from the 'MatrixClient.sync' event */ Crypto.prototype.onSyncCompleted = async function(syncData) { const nextSyncToken = syncData.nextSyncToken; this._deviceList.setSyncToken(syncData.nextSyncToken); this._deviceList.saveIfDirty(); // catch up on any new devices we got told about during the sync. this._deviceList.lastKnownSyncToken = nextSyncToken; // we always track our own device list (for key backups etc) this._deviceList.startTrackingDeviceList(this._userId); this._deviceList.refreshOutdatedDeviceLists(); // we don't start uploading one-time keys until we've caught up with // to-device messages, to help us avoid throwing away one-time-keys that we // are about to receive messages for // (https://github.com/vector-im/element-web/issues/2782). if (!syncData.catchingUp) { _maybeUploadOneTimeKeys(this); this._processReceivedRoomKeyRequests(); // likewise don't start requesting keys until we've caught up // on to_device messages, otherwise we'll request keys that we're // just about to get. this._outgoingRoomKeyRequestManager.sendQueuedRequests(); // Sync has finished so send key requests straight away. this._sendKeyRequestsImmediately = true; } }; /** * Trigger the appropriate invalidations and removes for a given * device list * * @param {Object} deviceLists device_lists field from /sync, or response from * /keys/changes */ Crypto.prototype._evalDeviceListChanges = async function(deviceLists) { if (deviceLists.changed && Array.isArray(deviceLists.changed)) { deviceLists.changed.forEach((u) => { this._deviceList.invalidateUserDeviceList(u); }); } if (deviceLists.left && Array.isArray(deviceLists.left) && deviceLists.left.length) { // Check we really don't share any rooms with these users // any more: the server isn't required to give us the // exact correct set. const e2eUserIds = new Set(await this._getTrackedE2eUsers()); deviceLists.left.forEach((u) => { if (!e2eUserIds.has(u)) { this._deviceList.stopTrackingDeviceList(u); } }); } }; /** * Get a list of all the IDs of users we share an e2e room with * for which we are tracking devices already * * @returns {string[]} List of user IDs */ Crypto.prototype._getTrackedE2eUsers = async function() { const e2eUserIds = []; for (const room of this._getTrackedE2eRooms()) { const members = await room.getEncryptionTargetMembers(); for (const member of members) { e2eUserIds.push(member.userId); } } return e2eUserIds; }; /** * Get a list of the e2e-enabled rooms we are members of, * and for which we are already tracking the devices * * @returns {module:models.Room[]} */ Crypto.prototype._getTrackedE2eRooms = function() { return this._clientStore.getRooms().filter((room) => { // check for rooms with encryption enabled const alg = this._roomEncryptors[room.roomId]; if (!alg) { return false; } if (!this._roomDeviceTrackingState[room.roomId]) { return false; } // ignore any rooms which we have left const myMembership = room.getMyMembership(); return myMembership === "join" || myMembership === "invite"; }); }; Crypto.prototype._onToDeviceEvent = function(event) { try { logger.log(`received to_device ${event.getType()} from: ` + `${event.getSender()} id: ${event.getId()}`); if (event.getType() == "m.room_key" || event.getType() == "m.forwarded_room_key") { this._onRoomKeyEvent(event); } else if (event.getType() == "m.room_key_request") { this._onRoomKeyRequestEvent(event); } else if (event.getType() === "m.secret.request") { this._secretStorage._onRequestReceived(event); } else if (event.getType() === "m.secret.send") { this._secretStorage._onSecretReceived(event); } else if (event.getType() === "org.matrix.room_key.withheld") { this._onRoomKeyWithheldEvent(event); } else if (event.getContent().transaction_id) { this._onKeyVerificationMessage(event); } else if (event.getContent().msgtype === "m.bad.encrypted") { this._onToDeviceBadEncrypted(event); } else if (event.isBeingDecrypted()) { // 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 withheld event * * @private * @param {module:models/event.MatrixEvent} event key withheld event */ Crypto.prototype._onRoomKeyWithheldEvent = function(event) { const content = event.getContent(); if ((content.code !== "m.no_olm" && (!content.room_id || !content.session_id)) || !content.algorithm || !content.sender_key) { logger.error("key withheld event is missing fields"); return; } logger.info( `Got room key withheld event from ${event.getSender()} (${content.sender_key}) ` + `for ${content.algorithm}/${content.room_id}/${content.session_id} ` + `with reason ${content.code} (${content.reason})`, ); const alg = this._getRoomDecryptor(content.room_id, content.algorithm); if (alg.onRoomKeyWithheldEvent) { alg.onRoomKeyWithheldEvent(event); } if (!content.room_id) { // retry decryption for all events sent by the sender_key. This will // update the events to show a message indicating that the olm session was // wedged. const roomDecryptors = this._getRoomDecryptors(content.algorithm); for (const decryptor of roomDecryptors) { decryptor.retryDecryptionFromSender(content.sender_key); } } }; /** * Handle a general key verification event. * * @private * @param {module:models/event.MatrixEvent} event verification start event */ Crypto.prototype._onKeyVerificationMessage = function(event) { if (!ToDeviceChannel.validateEvent(event, this._baseApis)) { return; } const createRequest = event => { if (!ToDeviceChannel.canCreateRequest(ToDeviceChannel.getEventType(event))) { return; } const content = event.getContent(); const deviceId = content && content.from_device; if (!deviceId) { return; } const userId = event.getSender(); const channel = new ToDeviceChannel( this._baseApis, userId, [deviceId], ); return new VerificationRequest( channel, this._verificationMethods, this._baseApis); }; this._handleVerificationEvent( event, this._toDeviceVerificationRequests, createRequest, ); }; /** * Handle key verification requests sent as timeline events * * @private * @param {module:models/event.MatrixEvent} event the timeline event * @param {module:models/Room} room not used * @param {bool} atStart not used * @param {bool} removed not used * @param {bool} data.liveEvent whether this is a live event */ Crypto.prototype._onTimelineEvent = function( event, room, atStart, removed, {liveEvent} = {}, ) { if (!InRoomChannel.validateEvent(event, this._baseApis)) { return; } const createRequest = event => { const channel = new InRoomChannel( this._baseApis, event.getRoomId(), ); return new VerificationRequest( channel, this._verificationMethods, this._baseApis); }; this._handleVerificationEvent( event, this._inRoomVerificationRequests, createRequest, liveEvent, ); }; Crypto.prototype._handleVerificationEvent = async function( event, requestsMap, createRequest, isLiveEvent = true, ) { let request = requestsMap.getRequest(event); let isNewRequest = false; if (!request) { request = createRequest(event); // a request could not be made from this event, so ignore event if (!request) { logger.log(`Crypto: could not find VerificationRequest for ` + `${event.getType()}, and could not create one, so ignoring.`); return; } isNewRequest = true; requestsMap.setRequest(event, request); } event.setVerificationRequest(request); try { await request.channel.handleEvent(event, request, isLiveEvent); } catch (err) { logger.error("error while handling verification event: " + err.message); } const shouldEmit = isNewRequest && !request.initiatedByMe && !request.invalid && // check it has enough events to pass the UNSENT stage !request.observeOnly; if (shouldEmit) { this._baseApis.emit("crypto.verification.request", request); } }; /** * Handle a toDevice event that couldn't be decrypted * * @private * @param {module:models/event.MatrixEvent} event undecryptable event */ Crypto.prototype._onToDeviceBadEncrypted = async function(event) { const content = event.getWireContent(); const sender = event.getSender(); const algorithm = content.algorithm; const deviceKey = content.sender_key; // retry decryption for all events sent by the sender_key. This will // update the events to show a message indicating that the olm session was // wedged. const retryDecryption = () => { const roomDecryptors = this._getRoomDecryptors(olmlib.MEGOLM_ALGORITHM); for (const decryptor of roomDecryptors) { decryptor.retryDecryptionFromSender(deviceKey); } }; if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { return; } // check when we last forced a new session with this device: if we've already done so // recently, don't do it again. this._lastNewSessionForced[sender] = this._lastNewSessionForced[sender] || {}; const lastNewSessionForced = this._lastNewSessionForced[sender][deviceKey] || 0; if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) { logger.debug( "New session already forced with device " + sender + ":" + deviceKey + " at " + lastNewSessionForced + ": not forcing another", ); await this._olmDevice.recordSessionProblem(deviceKey, "wedged", true); retryDecryption(); return; } // establish a new olm session with this device since we're failing to decrypt messages // on a current session. // Note that an undecryptable message from another device could easily be spoofed - // is there anything we can do to mitigate this? let device = this._deviceList.getDeviceByIdentityKey(algorithm, deviceKey); if (!device) { // if we don't know about the device, fetch the user's devices again // and retry before giving up await this.downloadKeys([sender], false); device = this._deviceList.getDeviceByIdentityKey(algorithm, deviceKey); if (!device) { logger.info( "Couldn't find device for identity key " + deviceKey + ": not re-establishing session", ); await this._olmDevice.recordSessionProblem(deviceKey, "wedged", false); retryDecryption(); return; } } const devicesByUser = {}; devicesByUser[sender] = [device]; await olmlib.ensureOlmSessionsForDevices( this._olmDevice, this._baseApis, devicesByUser, true, ); this._lastNewSessionForced[sender][deviceKey] = Date.now(); // Now send a blank message on that session so the other side knows about it. // (The keyshare request is sent in the clear so that won't do) // We send this first such that, as long as the toDevice messages arrive in the // same order we sent them, the other end will get this first, set up the new session, // then get the keyshare request and send the key over this new session (because it // is the session it has most recently received a message on). const encryptedContent = { algorithm: olmlib.OLM_ALGORITHM, sender_key: this._olmDevice.deviceCurve25519Key, ciphertext: {}, }; await olmlib.encryptMessageForDevice( encryptedContent.ciphertext, this._userId, this._deviceId, this._olmDevice, sender, device, {type: "m.dummy"}, ); await this._olmDevice.recordSessionProblem(deviceKey, "wedged", true); retryDecryption(); await this._baseApis.sendToDevice("m.room.encrypted", { [sender]: { [device.deviceId]: encryptedContent, }, }); // Most of the time this probably won't be necessary since we'll have queued up a key request when // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending // it. This won't always be the case though so we need to re-send any that have already been sent // to avoid races. const requestsToResend = await this._outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest( sender, device.deviceId, ); for (const keyReq of requestsToResend) { this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true); } }; /** * Handle a change in the membership state of a member of a room * * @private * @param {module:models/event.MatrixEvent} event event causing the change * @param {module:models/room-member} member user whose membership changed * @param {string=} oldMembership previous membership */ Crypto.prototype._onRoomMembership = function(event, member, oldMembership) { // this event handler is registered on the *client* (as opposed to the room // member itself), which means it is only called on changes to the *live* // membership state (ie, it is not called when we back-paginate, nor when // we load the state in the initialsync). // // Further, it is automatically registered and called when new members // arrive in the room. const roomId = member.roomId; const alg = this._roomEncryptors[roomId]; if (!alg) { // not encrypting in this room return; } // only mark users in this room as tracked if we already started tracking in this room // this way we don't start device queries after sync on behalf of this room which we won't use // the result of anyway, as we'll need to do a query again once all the members are fetched // by calling _trackRoomDevices if (this._roomDeviceTrackingState[roomId]) { if (member.membership == 'join') { logger.log('Join event for ' + member.userId + ' in ' + roomId); // make sure we are tracking the deviceList for this user this._deviceList.startTrackingDeviceList(member.userId); } else if (member.membership == 'invite' && this._clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) { logger.log('Invite event for ' + member.userId + ' in ' + roomId); this._deviceList.startTrackingDeviceList(member.userId); } } alg.onRoomMembership(event, member, oldMembership); }; /** * Called when we get an m.room_key_request event. * * @private * @param {module:models/event.MatrixEvent} event key request event */ Crypto.prototype._onRoomKeyRequestEvent = function(event) { const content = event.getContent(); if (content.action === "request") { // Queue it up for now, because they tend to arrive before the room state // events at initial sync, and we want to see if we know anything about the // room before passing them on to the app. const req = new IncomingRoomKeyRequest(event); this._receivedRoomKeyRequests.push(req); } else if (content.action === "request_cancellation") { const req = new IncomingRoomKeyRequestCancellation(event); this._receivedRoomKeyRequestCancellations.push(req); } }; /** * Process any m.room_key_request events which were queued up during the * current sync. * * @private */ Crypto.prototype._processReceivedRoomKeyRequests = async function() { if (this._processingRoomKeyRequests) { // we're still processing last time's requests; keep queuing new ones // up for now. return; } this._processingRoomKeyRequests = true; try { // we need to grab and clear the queues in the synchronous bit of this method, // so that we don't end up racing with the next /sync. const requests = this._receivedRoomKeyRequests; this._receivedRoomKeyRequests = []; const cancellations = this._receivedRoomKeyRequestCancellations; this._receivedRoomKeyRequestCancellations = []; // Process all of the requests, *then* all of the cancellations. // // This makes sure that if we get a request and its cancellation in the // same /sync result, then we process the request before the // cancellation (and end up with a cancelled request), rather than the // cancellation before the request (and end up with an outstanding // request which should have been cancelled.) await Promise.all(requests.map((req) => this._processReceivedRoomKeyRequest(req))); await Promise.all(cancellations.map((cancellation) => this._processReceivedRoomKeyRequestCancellation(cancellation))); } catch (e) { logger.error(`Error processing room key requsts: ${e}`); } finally { this._processingRoomKeyRequests = false; } }; /** * Helper for processReceivedRoomKeyRequests * * @param {IncomingRoomKeyRequest} req */ Crypto.prototype._processReceivedRoomKeyRequest = async function(req) { const userId = req.userId; const deviceId = req.deviceId; const body = req.requestBody; const roomId = body.room_id; const alg = body.algorithm; logger.log(`m.room_key_request from ${userId}:${deviceId}` + ` for ${roomId} / ${body.session_id} (id ${req.requestId})`); if (userId !== this._userId) { if (!this._roomEncryptors[roomId]) { logger.debug(`room key request for unencrypted room ${roomId}`); return; } const encryptor = this._roomEncryptors[roomId]; const device = this._deviceList.getStoredDevice(userId, deviceId); if (!device) { logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); return; } try { await encryptor.reshareKeyWithDevice( body.sender_key, body.session_id, userId, device, ); } catch (e) { logger.warn( "Failed to re-share keys for session " + body.session_id + " with device " + userId + ":" + device.deviceId, e, ); } return; } if (deviceId === this._deviceId) { // We'll always get these because we send room key requests to // '*' (ie. 'all devices') which includes the sending device, // so ignore requests from ourself because apart from it being // very silly, it won't work because an Olm session cannot send // messages to itself. // The log here is probably superfluous since we know this will // always happen, but let's log anyway for now just in case it // causes issues. logger.log("Ignoring room key request from ourselves"); return; } // todo: should we queue up requests we don't yet have keys for, // in case they turn up later? // if we don't have a decryptor for this room/alg, we don't have // the keys for the requested events, and can drop the requests. if (!this._roomDecryptors[roomId]) { logger.log(`room key request for unencrypted room ${roomId}`); return; } const decryptor = this._roomDecryptors[roomId][alg]; if (!decryptor) { logger.log(`room key request for unknown alg ${alg} in room ${roomId}`); return; } if (!await decryptor.hasKeysForKeyRequest(req)) { logger.log( `room key request for unknown session ${roomId} / ` + body.session_id, ); return; } req.share = () => { decryptor.shareKeysWithDevice(req); }; // if the device is verified already, share the keys if (this.checkDeviceTrust(userId, deviceId).isVerified()) { logger.log('device is already verified: sharing keys'); req.share(); return; } this.emit("crypto.roomKeyRequest", req); }; /** * Helper for processReceivedRoomKeyRequests * * @param {IncomingRoomKeyRequestCancellation} cancellation */ Crypto.prototype._processReceivedRoomKeyRequestCancellation = async function( cancellation, ) { logger.log( `m.room_key_request cancellation for ${cancellation.userId}:` + `${cancellation.deviceId} (id ${cancellation.requestId})`, ); // we should probably only notify the app of cancellations we told it // about, but we don't currently have a record of that, so we just pass // everything through. this.emit("crypto.roomKeyRequestCancellation", cancellation); }; /** * Get a decryptor for a given room and algorithm. * * If we already have a decryptor for the given room and algorithm, return * it. Otherwise try to instantiate it. * * @private * * @param {string?} roomId room id for decryptor. If undefined, a temporary * decryptor is instantiated. * * @param {string} algorithm crypto algorithm * * @return {module:crypto.algorithms.base.DecryptionAlgorithm} * * @raises {module:crypto.algorithms.DecryptionError} if the algorithm is * unknown */ Crypto.prototype._getRoomDecryptor = function(roomId, algorithm) { let decryptors; let alg; roomId = roomId || null; if (roomId) { decryptors = this._roomDecryptors[roomId]; if (!decryptors) { this._roomDecryptors[roomId] = decryptors = {}; } alg = decryptors[algorithm]; if (alg) { return alg; } } const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm]; if (!AlgClass) { throw new algorithms.DecryptionError( 'UNKNOWN_ENCRYPTION_ALGORITHM', 'Unknown encryption algorithm "' + algorithm + '".', ); } alg = new AlgClass({ userId: this._userId, crypto: this, olmDevice: this._olmDevice, baseApis: this._baseApis, roomId: roomId, }); if (decryptors) { decryptors[algorithm] = alg; } return alg; }; /** * Get all the room decryptors for a given encryption algorithm. * * @param {string} algorithm The encryption algorithm * * @return {array} An array of room decryptors */ Crypto.prototype._getRoomDecryptors = function(algorithm) { const decryptors = []; for (const d of Object.values(this._roomDecryptors)) { if (algorithm in d) { decryptors.push(d[algorithm]); } } return decryptors; }; /** * sign the given object with our ed25519 key * * @param {Object} obj Object to which we will add a 'signatures' property */ Crypto.prototype._signObject = async function(obj) { const sigs = obj.signatures || {}; const unsigned = obj.unsigned; delete obj.signatures; delete obj.unsigned; sigs[this._userId] = sigs[this._userId] || {}; sigs[this._userId]["ed25519:" + this._deviceId] = await this._olmDevice.sign(anotherjson.stringify(obj)); obj.signatures = sigs; if (unsigned !== undefined) obj.unsigned = unsigned; }; /** * The parameters of a room key request. The details of the request may * vary with the crypto algorithm, but the management and storage layers for * outgoing requests expect it to have 'room_id' and 'session_id' properties. * * @typedef {Object} RoomKeyRequestBody */ /** * Represents a received m.room_key_request event * * @property {string} userId user requesting the key * @property {string} deviceId device requesting the key * @property {string} requestId unique id for the request * @property {module:crypto~RoomKeyRequestBody} requestBody * @property {function()} share callback which, when called, will ask * the relevant crypto algorithm implementation to share the keys for * this request. */ class IncomingRoomKeyRequest { constructor(event) { const content = event.getContent(); this.userId = event.getSender(); this.deviceId = content.requesting_device_id; this.requestId = content.request_id; this.requestBody = content.body || {}; this.share = () => { throw new Error("don't know how to share keys for this request yet"); }; } } /** * Represents a received m.room_key_request cancellation * * @property {string} userId user requesting the cancellation * @property {string} deviceId device requesting the cancellation * @property {string} requestId unique id for the request to be cancelled */ class IncomingRoomKeyRequestCancellation { constructor(event) { const content = event.getContent(); this.userId = event.getSender(); this.deviceId = content.requesting_device_id; this.requestId = content.request_id; } } /** * The result of a (successful) call to decryptEvent. * * @typedef {Object} EventDecryptionResult * * @property {Object} clearEvent The plaintext payload for the event * (typically containing type and content fields). * * @property {?string} senderCurve25519Key Key owned by the sender of this * event. See {@link module:models/event.MatrixEvent#getSenderKey}. * * @property {?string} claimedEd25519Key ed25519 key claimed by the sender of * this event. See * {@link module:models/event.MatrixEvent#getClaimedEd25519Key}. * * @property {?Array} forwardingCurve25519KeyChain list of curve25519 * keys involved in telling us about the senderCurve25519Key and * claimedEd25519Key. See * {@link module:models/event.MatrixEvent#getForwardingCurve25519KeyChain}. */ /** * Fires when we receive a room key request * * @event module:client~MatrixClient#"crypto.roomKeyRequest" * @param {module:crypto~IncomingRoomKeyRequest} req request details */ /** * Fires when we receive a room key request cancellation * * @event module:client~MatrixClient#"crypto.roomKeyRequestCancellation" * @param {module:crypto~IncomingRoomKeyRequestCancellation} req */ /** * Fires when the app may wish to warn the user about something related * the end-to-end crypto. * * @event module:client~MatrixClient#"crypto.warning" * @param {string} type One of the strings listed above */ matrix-js-sdk-9.11.0/src/crypto/key_passphrase.js000066400000000000000000000045501403504662500217760ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {randomString} from '../randomstring'; const DEFAULT_ITERATIONS = 500000; const DEFAULT_BITSIZE = 256; export async function keyFromAuthData(authData, password) { if (!global.Olm) { throw new Error("Olm is not available"); } 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, authData.private_key_salt, authData.private_key_iterations, authData.private_key_bits || DEFAULT_BITSIZE, ); } export async function keyFromPassphrase(password) { if (!global.Olm) { throw new Error("Olm is not available"); } const salt = randomString(32); const key = await deriveKey(password, salt, DEFAULT_ITERATIONS, DEFAULT_BITSIZE); return { key, salt, iterations: DEFAULT_ITERATIONS }; } export async function deriveKey(password, salt, iterations, numBits = DEFAULT_BITSIZE) { 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, numBits, ); return new Uint8Array(keybits); } matrix-js-sdk-9.11.0/src/crypto/olmlib.js000066400000000000000000000441131403504662500202320ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2019 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module olmlib * * Utilities common to olm encryption algorithms */ import {logger} from '../logger'; import * as utils from "../utils"; import anotherjson from "another-json"; /** * matrix algorithm tag for olm */ export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; /** * matrix algorithm tag for megolm */ export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; /** * matrix algorithm tag for megolm backups */ export const 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` */ export async function encryptMessageForDevice( 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), ); } /** * Get the existing olm sessions for the given devices, and the devices that * don't have olm sessions. * * @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 * * @return {Promise} resolves to an array. The first element of the array is a * a map of user IDs to arrays of deviceInfo, representing the devices that * don't have established olm sessions. The second element of the array is * a map from userId to deviceId to {@link module:crypto~OlmSessionResult} */ export async function getExistingOlmSessions( olmDevice, baseApis, devicesByUser, ) { const devicesWithoutSession = {}; const sessions = {}; const promises = []; for (const [userId, devices] of Object.entries(devicesByUser)) { for (const deviceInfo of devices) { const deviceId = deviceInfo.deviceId; const key = deviceInfo.getIdentityKey(); promises.push((async () => { const sessionId = await olmDevice.getSessionIdForDevice( key, true, ); if (sessionId === null) { devicesWithoutSession[userId] = devicesWithoutSession[userId] || []; devicesWithoutSession[userId].push(deviceInfo); } else { sessions[userId] = sessions[userId] || {}; sessions[userId][deviceId] = { device: deviceInfo, sessionId: sessionId, }; } })()); } } await Promise.all(promises); return [devicesWithoutSession, sessions]; } /** * 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 {boolean} [force=false] If true, establish a new session even if one * already exists. * * @param {Number} [otkTimeout] The timeout in milliseconds when requesting * one-time keys for establishing new olm sessions. * * @param {Array} [failedServers] An array to fill with remote servers that * failed to respond to one-time-key requests. * * @param {Logger} [log] A possibly customised log * * @return {Promise} resolves once the sessions are complete, to * an Object mapping from userId to deviceId to * {@link module:crypto~OlmSessionResult} */ export async function ensureOlmSessionsForDevices( olmDevice, baseApis, devicesByUser, force, otkTimeout, failedServers, log, ) { if (typeof force === "number") { log = failedServers; failedServers = otkTimeout; otkTimeout = force; force = false; } if (!log) { log = logger; } const devicesWithoutSession = [ // [userId, deviceId], ... ]; const result = {}; const resolveSession = {}; // Mark all sessions this task intends to update as in progress. It is // important to do this for all devices this task cares about in a single // synchronous operation, as otherwise it is possible to have deadlocks // where multiple tasks wait indefinitely on another task to update some set // of common devices. for (const [, devices] of Object.entries(devicesByUser)) { for (const deviceInfo of devices) { const key = deviceInfo.getIdentityKey(); if (key === olmDevice.deviceCurve25519Key) { // We don't start sessions with ourself, so there's no need to // mark it in progress. continue; } 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 => { resolveSession[key] = (...args) => { delete olmDevice._sessionsInProgress[key]; resolve(...args); }; }); } } } for (const [userId, devices] of Object.entries(devicesByUser)) { result[userId] = {}; for (const deviceInfo of devices) { const deviceId = deviceInfo.deviceId; const key = deviceInfo.getIdentityKey(); if (key === olmDevice.deviceCurve25519Key) { // We should never be trying to start a session with ourself. // Apart from talking to yourself being the first sign of madness, // olm sessions can't do this because they get confused when // they get a message and see that the 'other side' has started a // new chain when this side has an active sender chain. // If you see this message being logged in the wild, we should find // the thing that is trying to send Olm messages to itself and fix it. log.info("Attempted to start session with ourself! Ignoring"); // We must fill in the section in the return value though, as callers // expect it to be there. result[userId][deviceId] = { device: deviceInfo, sessionId: null, }; continue; } const forWhom = `for ${key} (${userId}:${deviceId})`; const sessionId = await olmDevice.getSessionIdForDevice( key, resolveSession[key], log, ); if (sessionId !== null && resolveSession[key]) { // we found a session, but we had marked the session as // in-progress, so resolve it now, which will unmark it and // unblock anything that was waiting resolveSession[key](); } if (sessionId === null || force) { if (force) { log.info(`Forcing new Olm session ${forWhom}`); } else { log.info(`Making new Olm session ${forWhom}`); } devicesWithoutSession.push([userId, deviceId]); } result[userId][deviceId] = { device: deviceInfo, sessionId: sessionId, }; } } if (devicesWithoutSession.length === 0) { return result; } const oneTimeKeyAlgorithm = "signed_curve25519"; let res; let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`; try { log.debug(`Claiming ${taskDetail}`); res = await baseApis.claimOneTimeKeys( devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout, ); log.debug(`Claimed ${taskDetail}`); } catch (e) { for (const resolver of Object.values(resolveSession)) { resolver(); } log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession); throw e; } if (failedServers && "failures" in res) { failedServers.push(...Object.keys(res.failures)); } const otkResult = res.one_time_keys || {}; const promises = []; for (const [userId, devices] of Object.entries(devicesByUser)) { const userRes = otkResult[userId] || {}; for (let j = 0; j < devices.length; j++) { const deviceInfo = devices[j]; const deviceId = deviceInfo.deviceId; const key = deviceInfo.getIdentityKey(); if (key === olmDevice.deviceCurve25519Key) { // We've already logged about this above. Skip here too // otherwise we'll log saying there are no one-time keys // which will be confusing. continue; } 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) { log.warn( `No one-time keys (alg=${oneTimeKeyAlgorithm}) ` + `for device ${userId}:${deviceId}`, ); if (resolveSession[key]) { resolveSession[key](); } continue; } promises.push( _verifyKeyAndStartSession( olmDevice, oneTimeKey, userId, deviceInfo, ).then((sid) => { if (resolveSession[key]) { resolveSession[key](sid); } result[userId][deviceId].sessionId = sid; }, (e) => { if (resolveSession[key]) { resolveSession[key](); } throw e; }), ); } } taskDetail = `Olm sessions for ${promises.length} devices`; log.debug(`Starting ${taskDetail}`); await Promise.all(promises); log.debug(`Started ${taskDetail}`); 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 olm session with device " + userId + ":" + deviceId + ": " + e); return null; } logger.log("Started new olm 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. * * @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. */ export async function verifySignature( 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 const mangledObj = Object.assign({}, obj); delete mangledObj.unsigned; delete mangledObj.signatures; const json = anotherjson.stringify(mangledObj); olmDevice.verifySignature( signingKey, json, signature, ); } /** * Sign a JSON object using public key cryptography * @param {Object} obj Object to sign. The object will be modified to include * the new signature * @param {Olm.PkSigning|Uint8Array} key the signing object or the private key * seed * @param {string} userId The user ID who owns the signing key * @param {string} pubkey The public key (ignored if key is a seed) * @returns {string} the signature for the object */ export function pkSign(obj, key, userId, pubkey) { let createdKey = false; if (key instanceof Uint8Array) { const keyObj = new global.Olm.PkSigning(); pubkey = keyObj.init_with_seed(key); key = keyObj; createdKey = true; } const sigs = obj.signatures || {}; delete obj.signatures; const unsigned = obj.unsigned; if (obj.unsigned) delete obj.unsigned; try { const mysigs = sigs[userId] || {}; sigs[userId] = mysigs; return mysigs['ed25519:' + pubkey] = key.sign(anotherjson.stringify(obj)); } finally { obj.signatures = sigs; if (unsigned) obj.unsigned = unsigned; if (createdKey) { key.free(); } } } /** * Verify a signed JSON object * @param {Object} obj Object to verify * @param {string} pubkey The public key to use to verify * @param {string} userId The user ID who signed the object */ export function pkVerify(obj, pubkey, userId) { const keyId = "ed25519:" + pubkey; if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) { throw new Error("No signature"); } const signature = obj.signatures[userId][keyId]; const util = new global.Olm.Utility(); const sigs = obj.signatures; delete obj.signatures; const unsigned = obj.unsigned; if (obj.unsigned) delete obj.unsigned; try { util.ed25519_verify(pubkey, anotherjson.stringify(obj), signature); } finally { obj.signatures = sigs; if (unsigned) obj.unsigned = unsigned; util.free(); } } /** * Encode a typed array of uint8 as base64. * @param {Uint8Array} uint8Array The data to encode. * @return {string} The base64. */ export function encodeBase64(uint8Array) { return Buffer.from(uint8Array).toString("base64"); } /** * Encode a typed array of uint8 as unpadded base64. * @param {Uint8Array} uint8Array The data to encode. * @return {string} The unpadded base64. */ export function encodeUnpaddedBase64(uint8Array) { return encodeBase64(uint8Array).replace(/=+$/g, ''); } /** * Decode a base64 string to a typed array of uint8. * @param {string} base64 The base64 to decode. * @return {Uint8Array} The decoded data. */ export function decodeBase64(base64) { return Buffer.from(base64, "base64"); } matrix-js-sdk-9.11.0/src/crypto/recoverykey.js000066400000000000000000000037241403504662500213260ustar00rootroot00000000000000/* 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 Uint8Array.from(result.slice( OLM_RECOVERY_KEY_PREFIX.length, OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH, )); } matrix-js-sdk-9.11.0/src/crypto/store/000077500000000000000000000000001403504662500175475ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/crypto/store/base.js000066400000000000000000000017761403504662500210320ustar00rootroot00000000000000/** * 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}) */ matrix-js-sdk-9.11.0/src/crypto/store/indexeddb-crypto-store-backend.js000066400000000000000000000761351403504662500261040ustar00rootroot00000000000000/* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {logger} from '../../logger'; import * as utils from "../../utils"; export const VERSION = 10; const PROFILE_TRANSACTIONS = false; /** * 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; this._nextTxnId = 0; // 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; return new Promise((resolve, reject) => { const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite"); txn.onerror = 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`, ); 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 = () => {resolve(request);}; const store = txn.objectStore("outgoingRoomKeyRequests"); store.add(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 new Promise((resolve, reject) => { const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly"); txn.onerror = reject; this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { resolve(existing); }); }); } /** * 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); } /** * * @param {Number} wantedState * @return {Promise>} All elements in a given state */ getAllOutgoingRoomKeyRequestsByState(wantedState) { return new Promise((resolve, reject) => { const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly"); const store = txn.objectStore("outgoingRoomKeyRequests"); const index = store.index("state"); const request = index.getAll(wantedState); request.onsuccess = (ev) => resolve(ev.target.result); request.onerror = (ev) => reject(ev.target.error); }); } 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, "-"); } getCrossSigningKeys(txn, func) { const objectStore = txn.objectStore("account"); const getReq = objectStore.get("crossSigningKeys"); getReq.onsuccess = function() { try { func(getReq.result || null); } catch (e) { abortWithException(txn, e); } }; } getSecretStorePrivateKey(txn, func, type) { const objectStore = txn.objectStore("account"); const getReq = objectStore.get(`ssss_cache:${type}`); getReq.onsuccess = function() { try { func(getReq.result || null); } catch (e) { abortWithException(txn, e); } }; } storeCrossSigningKeys(txn, keys) { const objectStore = txn.objectStore("account"); objectStore.put(keys, "crossSigningKeys"); } storeSecretStorePrivateKey(txn, type, key) { const objectStore = txn.objectStore("account"); objectStore.put(key, `ssss_cache:${type}`); } // Olm Sessions countEndToEndSessions(txn, func) { const objectStore = txn.objectStore("sessions"); const countReq = objectStore.count(); countReq.onsuccess = function() { try { func(countReq.result); } catch (e) { abortWithException(txn, e); } }; } 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() { try { const cursor = getReq.result; if (cursor) { func(cursor.value); cursor.continue(); } else { 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, }); } async storeEndToEndSessionProblem(deviceKey, type, fixed) { const txn = this._db.transaction("session_problems", "readwrite"); const objectStore = txn.objectStore("session_problems"); objectStore.put({ deviceKey, type, fixed, time: Date.now(), }); return promiseifyTxn(txn); } async getEndToEndSessionProblem(deviceKey, timestamp) { let result; const txn = this._db.transaction("session_problems", "readwrite"); const objectStore = txn.objectStore("session_problems"); const index = objectStore.index("deviceKey"); const req = index.getAll(deviceKey); req.onsuccess = (event) => { const problems = req.result; if (!problems.length) { result = null; return; } problems.sort((a, b) => { return a.time - b.time; }); const lastProblem = problems[problems.length - 1]; for (const problem of problems) { if (problem.time > timestamp) { result = Object.assign({}, problem, {fixed: lastProblem.fixed}); return; } } if (lastProblem.fixed) { result = null; } else { result = lastProblem; } }; await promiseifyTxn(txn); return result; } // FIXME: we should probably prune this when devices get deleted async filterOutNotifiedErrorDevices(devices) { const txn = this._db.transaction("notified_error_devices", "readwrite"); const objectStore = txn.objectStore("notified_error_devices"); const ret = []; await Promise.all(devices.map((device) => { return new Promise((resolve) => { const {userId, deviceInfo} = device; const getReq = objectStore.get([userId, deviceInfo.deviceId]); getReq.onsuccess = function() { if (!getReq.result) { objectStore.put({userId, deviceId: deviceInfo.deviceId}); ret.push(device); } resolve(); }; }); })); return ret; } // Inbound group sessions getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { let session = false; let withheld = false; const objectStore = txn.objectStore("inbound_group_sessions"); const getReq = objectStore.get([senderCurve25519Key, sessionId]); getReq.onsuccess = function() { try { if (getReq.result) { session = getReq.result.session; } else { session = null; } if (withheld !== false) { func(session, withheld); } } catch (e) { abortWithException(txn, e); } }; const withheldObjectStore = txn.objectStore("inbound_group_sessions_withheld"); const withheldGetReq = withheldObjectStore.get([senderCurve25519Key, sessionId]); withheldGetReq.onsuccess = function() { try { if (withheldGetReq.result) { withheld = withheldGetReq.result.session; } else { withheld = null; } if (session !== false) { func(session, withheld); } } 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, }); } storeEndToEndInboundGroupSessionWithheld( senderCurve25519Key, sessionId, sessionData, txn, ) { const objectStore = txn.objectStore("inbound_group_sessions_withheld"); 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; }); })); } addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) { if (!txn) { txn = this._db.transaction( "shared_history_inbound_group_sessions", "readwrite", ); } const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); const req = objectStore.get([roomId]); req.onsuccess = () => { const {sessions} = req.result || {sessions: []}; sessions.push([senderKey, sessionId]); objectStore.put({roomId, sessions}); }; } getSharedHistoryInboundGroupSessions(roomId, txn) { if (!txn) { txn = this._db.transaction( "shared_history_inbound_group_sessions", "readonly", ); } const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); const req = objectStore.get([roomId]); return new Promise((resolve, reject) => { req.onsuccess = () => { const {sessions} = req.result || {sessions: []}; resolve(sessions); }; req.onerror = reject; }); } doTxn(mode, stores, func, log = logger) { let startTime; let description; if (PROFILE_TRANSACTIONS) { const txnId = this._nextTxnId++; startTime = Date.now(); description = `${mode} crypto store transaction ${txnId} in ${stores}`; log.debug(`Starting ${description}`); } const txn = this._db.transaction(stores, mode); const promise = promiseifyTxn(txn); const result = func(txn); if (PROFILE_TRANSACTIONS) { promise.then(() => { const elapsedTime = Date.now() - startTime; log.debug(`Finished ${description}, took ${elapsedTime} ms`); }, () => { const elapsedTime = Date.now() - startTime; log.error(`Failed ${description}, took ${elapsedTime} ms`); }); } return promise.then(() => { return result; }); } } 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"], }); } if (oldVersion < 8) { db.createObjectStore("inbound_group_sessions_withheld", { keyPath: ["senderCurve25519Key", "sessionId"], }); } if (oldVersion < 9) { const problemsStore = db.createObjectStore("session_problems", { keyPath: ["deviceKey", "time"], }); problemsStore.createIndex("deviceKey", "deviceKey"); db.createObjectStore("notified_error_devices", { keyPath: ["userId", "deviceId"], }); } if (oldVersion < 10) { db.createObjectStore("shared_history_inbound_group_sessions", { keyPath: ["roomId"], }); } // 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); } }; }); } matrix-js-sdk-9.11.0/src/crypto/store/indexeddb-crypto-store.js000066400000000000000000000567711403504662500245230ustar00rootroot00000000000000/* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ 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 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; this._backend = 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. * * This must be called before the store can be used. * * @return {Promise} resolves to either an IndexedDBCryptoStoreBackend.Backend, * or a MemoryCryptoStore */ startup() { 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, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, ], (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(); } }).then(backend => { this._backend = backend; }); 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._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._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._backend.getOutgoingRoomKeyRequestByState(wantedStates); } /** * Look for room key requests by state – * unlike above, return a list of all entries in one state. * * @param {Number} wantedState * @return {Promise>} Returns an array of requests in the given state */ getAllOutgoingRoomKeyRequestsByState(wantedState) { return this._backend.getAllOutgoingRoomKeyRequestsByState(wantedState); } /** * 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._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._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._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._backend.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._backend.storeAccount(txn, newData); } /** * Get the public part of the cross-signing keys (eg. self-signing key, * user signing key). * * @param {*} txn An active transaction. See doTxn(). * @param {function(string)} func Called with the account keys object: * { key_type: base64 encoded seed } where key type = user_signing_key_seed or self_signing_key_seed */ getCrossSigningKeys(txn, func) { this._backend.getCrossSigningKeys(txn, func); } /** * @param {*} txn An active transaction. See doTxn(). * @param {function(string)} func Called with the private key * @param {string} type A key type */ getSecretStorePrivateKey(txn, func, type) { this._backend.getSecretStorePrivateKey(txn, func, type); } /** * Write the cross-signing keys back to the store * * @param {*} txn An active transaction. See doTxn(). * @param {string} keys keys object as getCrossSigningKeys() */ storeCrossSigningKeys(txn, keys) { this._backend.storeCrossSigningKeys(txn, keys); } /** * Write the cross-signing private keys back to the store * * @param {*} txn An active transaction. See doTxn(). * @param {string} type The type of cross-signing private key to store * @param {string} key keys object as getCrossSigningKeys() */ storeSecretStorePrivateKey(txn, type, key) { this._backend.storeSecretStorePrivateKey(txn, type, key); } // 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._backend.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._backend.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._backend.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._backend.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._backend.storeEndToEndSession( deviceKey, sessionId, sessionInfo, txn, ); } storeEndToEndSessionProblem(deviceKey, type, fixed) { return this._backend.storeEndToEndSessionProblem(deviceKey, type, fixed); } getEndToEndSessionProblem(deviceKey, timestamp) { return this._backend.getEndToEndSessionProblem(deviceKey, timestamp); } filterOutNotifiedErrorDevices(devices) { return this._backend.filterOutNotifiedErrorDevices(devices); } // Inbound group sessions /** * 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._backend.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._backend.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._backend.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._backend.storeEndToEndInboundGroupSession( senderCurve25519Key, sessionId, sessionData, txn, ); } storeEndToEndInboundGroupSessionWithheld( senderCurve25519Key, sessionId, sessionData, txn, ) { this._backend.storeEndToEndInboundGroupSessionWithheld( 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._backend.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._backend.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._backend.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._backend.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._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._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._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._backend.markSessionsNeedingBackup(sessions, txn); } /** * Add a shared-history group session for a room. * @param {string} roomId The room that the key belongs to * @param {string} senderKey The sender's curve 25519 key * @param {string} sessionId The ID of the session * @param {*} txn An active transaction. See doTxn(). (optional) */ addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) { this._backend.addSharedHistoryInboundGroupSession( roomId, senderKey, sessionId, txn, ); } /** * Get the shared-history group session for a room. * @param {string} roomId The room that the key belongs to * @param {*} txn An active transaction. See doTxn(). (optional) * @returns {Promise} Resolves to an array of [senderKey, sessionId] */ getSharedHistoryInboundGroupSessions(roomId, txn) { return this._backend.getSharedHistoryInboundGroupSessions(roomId, 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. * @param {Logger} [log] A possibly customised log * @return {Promise} Promise that resolves with the result of the `func` * when the transaction is complete. If the backend is * async (ie. the indexeddb backend) any of the callback * 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, log) { return this._backend.doTxn(mode, stores, func, log); } } IndexedDBCryptoStore.STORE_ACCOUNT = 'account'; IndexedDBCryptoStore.STORE_SESSIONS = 'sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD = 'inbound_group_sessions_withheld'; IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS = 'shared_history_inbound_group_sessions'; IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data'; IndexedDBCryptoStore.STORE_ROOMS = 'rooms'; IndexedDBCryptoStore.STORE_BACKUP = 'sessions_needing_backup'; matrix-js-sdk-9.11.0/src/crypto/store/localStorage-crypto-store.js000066400000000000000000000315321403504662500252000ustar00rootroot00000000000000/* Copyright 2017, 2018 New Vector Ltd Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {logger} from '../../logger'; import {MemoryCryptoStore} from './memory-crypto-store'; /** * 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_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys"; const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices"; const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/"; const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/"; const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup"; function keyEndToEndSessions(deviceKey) { return E2E_PREFIX + "sessions/" + deviceKey; } function keyEndToEndSessionProblems(deviceKey) { return E2E_PREFIX + "session.problems/" + deviceKey; } function keyEndToEndInboundGroupSession(senderKey, sessionId) { return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId; } function keyEndToEndInboundGroupSessionWithheld(senderKey, sessionId) { return KEY_INBOUND_SESSION_WITHHELD_PREFIX + senderKey + "/" + sessionId; } function keyEndToEndRoomsPrefix(roomId) { return KEY_ROOMS_PREFIX + roomId; } /** * @implements {module:crypto/store/base~CryptoStore} */ export 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, ); } async storeEndToEndSessionProblem(deviceKey, type, fixed) { const key = keyEndToEndSessionProblems(deviceKey); const problems = getJsonItem(this.store, key) || []; problems.push({type, fixed, time: Date.now()}); problems.sort((a, b) => { return a.time - b.time; }); setJsonItem(this.store, key, problems); } async getEndToEndSessionProblem(deviceKey, timestamp) { const key = keyEndToEndSessionProblems(deviceKey); const problems = getJsonItem(this.store, key) || []; if (!problems.length) { return null; } const lastProblem = problems[problems.length - 1]; for (const problem of problems) { if (problem.time > timestamp) { return Object.assign({}, problem, {fixed: lastProblem.fixed}); } } if (lastProblem.fixed) { return null; } else { return lastProblem; } } async filterOutNotifiedErrorDevices(devices) { const notifiedErrorDevices = getJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {}; const ret = []; for (const device of devices) { const {userId, deviceInfo} = device; if (userId in notifiedErrorDevices) { if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { ret.push(device); notifiedErrorDevices[userId][deviceInfo.deviceId] = true; } } else { ret.push(device); notifiedErrorDevices[userId] = {[deviceInfo.deviceId]: true }; } } setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices); return ret; } // Inbound Group Sessions getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { func( getJsonItem( this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), ), getJsonItem( this.store, keyEndToEndInboundGroupSessionWithheld(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, ); } storeEndToEndInboundGroupSessionWithheld( senderCurve25519Key, sessionId, sessionData, txn, ) { setJsonItem( this.store, keyEndToEndInboundGroupSessionWithheld(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, ); } getCrossSigningKeys(txn, func) { const keys = getJsonItem(this.store, KEY_CROSS_SIGNING_KEYS); func(keys); } getSecretStorePrivateKey(txn, func, type) { const key = getJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`); func(key); } storeCrossSigningKeys(txn, keys) { setJsonItem( this.store, KEY_CROSS_SIGNING_KEYS, keys, ); } storeSecretStorePrivateKey(txn, type, key) { setJsonItem( this.store, E2E_PREFIX + `ssss_cache.${type}`, key, ); } 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)); } matrix-js-sdk-9.11.0/src/crypto/store/memory-crypto-store.js000066400000000000000000000361271403504662500240760ustar00rootroot00000000000000/* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {logger} from '../../logger'; import * as utils from "../../utils"; /** * Internal module. in-memory storage for e2e. * * @module */ /** * @implements {module:crypto/store/base~CryptoStore} */ export class MemoryCryptoStore { constructor() { this._outgoingRoomKeyRequests = []; this._account = null; this._crossSigningKeys = null; this._privateKeys = {}; this._backupKeys = {}; // Map of {devicekey -> {sessionId -> session pickle}} this._sessions = {}; // Map of {devicekey -> array of problems} this._sessionProblems = {}; // Map of {userId -> deviceId -> true} this._notifiedErrorDevices = {}; // Map of {senderCurve25519Key+'/'+sessionId -> session data object} this._inboundGroupSessions = {}; this._inboundGroupSessionsWithheld = {}; // Opaque device data object this._deviceData = null; // roomId -> Opaque roomInfo object this._rooms = {}; // Set of {senderCurve25519Key+'/'+sessionId} this._sessionsNeedingBackup = {}; // roomId -> array of [senderKey, sessionId] this._sharedHistoryInboundGroupSessions = {}; } /** * Ensure the database exists and is up-to-date. * * This must be called before the store can be used. * * @return {Promise} resolves to the store. */ async startup() { // No startup work to do for the memory store. return this; } /** * 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 utils.promiseTry(() => { // 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); } /** * * @param {Number} wantedState * @return {Promise>} All OutgoingRoomKeyRequests in state */ getAllOutgoingRoomKeyRequestsByState(wantedState) { return Promise.resolve( this._outgoingRoomKeyRequests.filter( (r) => r.state == wantedState, ), ); } 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; } getCrossSigningKeys(txn, func) { func(this._crossSigningKeys); } getSecretStorePrivateKey(txn, func, type) { const result = this._privateKeys[type]; return func(result || null); } storeCrossSigningKeys(txn, keys) { this._crossSigningKeys = keys; } storeSecretStorePrivateKey(txn, type, key) { this._privateKeys[type] = key; } // 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) { Object.entries(this._sessions).forEach(([deviceKey, deviceSessions]) => { Object.entries(deviceSessions).forEach(([sessionId, session]) => { func({ ...session, deviceKey, sessionId, }); }); }); } storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { let deviceSessions = this._sessions[deviceKey]; if (deviceSessions === undefined) { deviceSessions = {}; this._sessions[deviceKey] = deviceSessions; } deviceSessions[sessionId] = sessionInfo; } async storeEndToEndSessionProblem(deviceKey, type, fixed) { const problems = this._sessionProblems[deviceKey] = this._sessionProblems[deviceKey] || []; problems.push({type, fixed, time: Date.now()}); problems.sort((a, b) => { return a.time - b.time; }); } async getEndToEndSessionProblem(deviceKey, timestamp) { const problems = this._sessionProblems[deviceKey] || []; if (!problems.length) { return null; } const lastProblem = problems[problems.length - 1]; for (const problem of problems) { if (problem.time > timestamp) { return Object.assign({}, problem, {fixed: lastProblem.fixed}); } } if (lastProblem.fixed) { return null; } else { return lastProblem; } } async filterOutNotifiedErrorDevices(devices) { const notifiedErrorDevices = this._notifiedErrorDevices; const ret = []; for (const device of devices) { const {userId, deviceInfo} = device; if (userId in notifiedErrorDevices) { if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) { ret.push(device); notifiedErrorDevices[userId][deviceInfo.deviceId] = true; } } else { ret.push(device); notifiedErrorDevices[userId] = {[deviceInfo.deviceId]: true }; } } return ret; } // Inbound Group Sessions getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { const k = senderCurve25519Key+'/'+sessionId; func( this._inboundGroupSessions[k] || null, this._inboundGroupSessionsWithheld[k] || 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; } storeEndToEndInboundGroupSessionWithheld( senderCurve25519Key, sessionId, sessionData, txn, ) { const k = senderCurve25519Key+'/'+sessionId; this._inboundGroupSessionsWithheld[k] = 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(); } addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId) { const sessions = this._sharedHistoryInboundGroupSessions[roomId] || []; sessions.push([senderKey, sessionId]); this._sharedHistoryInboundGroupSessions[roomId] = sessions; } getSharedHistoryInboundGroupSessions(roomId) { return Promise.resolve(this._sharedHistoryInboundGroupSessions[roomId] || []); } // Session key backups doTxn(mode, stores, func) { return Promise.resolve(func(null)); } } matrix-js-sdk-9.11.0/src/crypto/verification/000077500000000000000000000000001403504662500210755ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/crypto/verification/Base.js000066400000000000000000000300571403504662500223120ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Base class for verification methods. * @module crypto/verification/Base */ import {MatrixEvent} from '../../models/event'; import {EventEmitter} from 'events'; import {logger} from '../../logger'; import {DeviceInfo} from '../deviceinfo'; import {newTimeoutError} from "./Error"; import {requestKeysDuringVerification} from "../CrossSigning"; const timeoutException = new Error("Verification timed out"); export class SwitchStartEventError extends Error { constructor(startEvent) { super(); this.startEvent = startEvent; } } export 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~Channel} channel the verification channel to send verification messages over. * * @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 {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 */ constructor(channel, baseApis, userId, deviceId, startEvent, request) { super(); this._channel = channel; this._baseApis = baseApis; this.userId = userId; this.deviceId = deviceId; this.startEvent = startEvent; this.request = request; this.cancelled = false; this._done = false; this._promise = null; 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(); } _resetTimer() { logger.info("Refreshing/starting the verification transaction timeout timer"); if (this._transactionTimeoutTimer !== null) { clearTimeout(this._transactionTimeoutTimer); } this._transactionTimeoutTimer = setTimeout(() => { if (!this._done && !this.cancelled) { logger.info("Triggering verification timeout"); this.cancel(timeoutException); } }, 10 * 60 * 1000); // 10 minutes } _endTimer() { if (this._transactionTimeoutTimer !== null) { clearTimeout(this._transactionTimeoutTimer); this._transactionTimeoutTimer = null; } } _send(type, uncompletedContent) { return this._channel.send(type, uncompletedContent); } _waitForEvent(type) { if (this._done) { return Promise.reject(new Error("Verification is already done")); } const existingEvent = this.request.getEventFromOtherParty(type); if (existingEvent) { return Promise.resolve(existingEvent); } this._expectedEvent = type; return new Promise((resolve, reject) => { this._resolveEvent = resolve; this._rejectEvent = reject; }); } canSwitchStartEvent() { return false; } switchStartEvent(event) { if (this.canSwitchStartEvent(event)) { logger.log("Verification Base: switching verification start event", {restartingFlow: !!this._rejectEvent}); if (this._rejectEvent) { const reject = this._rejectEvent; this._rejectEvent = undefined; reject(new SwitchStartEventError(event)); } else { this.startEvent = event; } } } handleEvent(e) { if (this._done) { return; } else if (e.getType() === this._expectedEvent) { // if we receive an expected m.key.verification.done, then just // ignore it, since we don't need to do anything about it if (this._expectedEvent !== "m.key.verification.done") { this._expectedEvent = undefined; this._rejectEvent = undefined; this._resetTimer(); this._resolveEvent(e); } } else if (e.getType() === "m.key.verification.cancel") { const reject = this._reject; this._reject = undefined; // there is only promise to reject if verify has been called if (reject) { const content = e.getContent(); const {reason, code} = content; reject(new Error(`Other side cancelled verification ` + `because ${reason} (${code})`)); } } else if (this._expectedEvent) { // only cancel if there is an event expected. // if there is no event expected, it means verify() wasn't called // and we're just replaying the timeline events when syncing // after a refresh when the events haven't been stored in the cache yet. const exception = new Error( "Unexpected message: expecting " + this._expectedEvent + " but got " + e.getType(), ); this._expectedEvent = undefined; 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.request.onVerifierFinished(); this._resolve(); return requestKeysDuringVerification(this._baseApis, this.userId, this.deviceId); } } cancel(e) { this._endTimer(); // always kill the activity timer if (!this._done) { this.cancelled = true; this.request.onVerifierCancelled(); if (this.userId && this.deviceId) { // send a cancellation to the other user (if it wasn't // cancelled by the other user) if (e === timeoutException) { const timeoutEvent = newTimeoutError(); this._send(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"; this._send("m.key.verification.cancel", content); } else { this._send("m.key.verification.cancel", { code: "m.unknown", reason: content.body || "Unknown reason", }); } } } else { this._send("m.key.verification.cancel", { code: "m.unknown", reason: e.toString(), }); } } 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 { // FIXME: this causes an "Uncaught promise" console message // if nothing ends up chaining this promise. 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 = this._baseApis.getStoredDevice(userId, deviceId); if (device) { await verifier(keyId, device, keyInfo); verifiedDevices.push(deviceId); } else { const crossSigningInfo = this._baseApis._crypto._deviceList .getStoredCrossSigningForUser(userId); if (crossSigningInfo && crossSigningInfo.getId() === deviceId) { await verifier(keyId, DeviceInfo.fromStorage({ keys: { [keyId]: deviceId, }, }, deviceId), keyInfo); verifiedDevices.push(deviceId); } else { logger.warn( `verification: Could not find device ${deviceId} to verify`, ); } } } // 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"); } logger.info( "Verification completed! Marking devices verified: ", verifiedDevices, ); // TODO: There should probably be a batch version of this, otherwise it's going // to upload each signature in a separate API call which is silly because the // API supports as many signatures as you like. for (const deviceId of verifiedDevices) { await this._baseApis.setDeviceVerified(userId, deviceId); } } } matrix-js-sdk-9.11.0/src/crypto/verification/Error.js000066400000000000000000000045161403504662500225320ustar00rootroot00000000000000/* 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) { const content = Object.assign({}, {code, reason}, extradata); return new MatrixEvent({ type: "m.key.verification.cancel", content, }); } 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", ); export function errorFromEvent(event) { const content = event.getContent(); if (content) { const {code, reason} = content; return {code, reason}; } else { return {code: "Unknown error", reason: "m.unknown"}; } } matrix-js-sdk-9.11.0/src/crypto/verification/IllegalMethod.js000066400000000000000000000024621403504662500241510ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * Verification method that is illegal to have (cannot possibly * do verification with this method). * @module crypto/verification/IllegalMethod */ import {VerificationBase as Base} from "./Base"; /** * @class crypto/verification/IllegalMethod/IllegalMethod * @extends {module:crypto/verification/Base} */ export class IllegalMethod extends Base { static factory(...args) { return new IllegalMethod(...args); } static get NAME() { // Typically the name will be something else, but to complete // the contract we offer a default one here. return "org.matrix.illegal_method"; } async _doVerification() { throw new Error("Verification is not possible with this method"); } } matrix-js-sdk-9.11.0/src/crypto/verification/QRCode.js000066400000000000000000000252601403504662500225550ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * QR code key verification. * @module crypto/verification/QRCode */ import {VerificationBase as Base} from "./Base"; import { newKeyMismatchError, newUserCancelledError, } from './Error'; import {encodeUnpaddedBase64, decodeBase64} from "../olmlib"; import {logger} from '../../logger'; export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1"; export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1"; /** * @class crypto/verification/QRCode/ReciprocateQRCode * @extends {module:crypto/verification/Base} */ export class ReciprocateQRCode extends Base { static factory(...args) { return new ReciprocateQRCode(...args); } static get NAME() { return "m.reciprocate.v1"; } async _doVerification() { if (!this.startEvent) { // TODO: Support scanning QR codes throw new Error("It is not currently possible to start verification" + "with this method yet."); } const {qrCodeData} = this.request; // 1. check the secret if (this.startEvent.getContent()['secret'] !== qrCodeData.encodedSharedSecret) { throw newKeyMismatchError(); } // 2. ask if other user shows shield as well await new Promise((resolve, reject) => { this.reciprocateQREvent = { confirm: resolve, cancel: () => reject(newUserCancelledError()), }; this.emit("show_reciprocate_qr", this.reciprocateQREvent); }); // 3. determine key to sign / mark as trusted const keys = {}; switch (qrCodeData.mode) { case MODE_VERIFY_OTHER_USER: { // add master key to keys to be signed, only if we're not doing self-verification const masterKey = qrCodeData.otherUserMasterKey; keys[`ed25519:${masterKey}`] = masterKey; break; } case MODE_VERIFY_SELF_TRUSTED: { const deviceId = this.request.targetDevice.deviceId; keys[`ed25519:${deviceId}`] = qrCodeData.otherDeviceKey; break; } case MODE_VERIFY_SELF_UNTRUSTED: { const masterKey = qrCodeData.myMasterKey; keys[`ed25519:${masterKey}`] = masterKey; break; } } // 4. sign the key (or mark own MSK as verified in case of MODE_VERIFY_SELF_TRUSTED) await this._verifyKeys(this.userId, keys, (keyId, device, keyInfo) => { // make sure the device has the expected keys const targetKey = keys[keyId]; if (!targetKey) throw newKeyMismatchError(); if (keyInfo !== targetKey) { logger.error("key ID from key info does not match"); throw newKeyMismatchError(); } for (const deviceKeyId in device.keys) { if (!deviceKeyId.startsWith("ed25519")) continue; const deviceTargetKey = keys[deviceKeyId]; if (!deviceTargetKey) throw newKeyMismatchError(); if (device.keys[deviceKeyId] !== deviceTargetKey) { logger.error("master key does not match"); throw newKeyMismatchError(); } } }); } } const CODE_VERSION = 0x02; // the version of binary QR codes we support const BINARY_PREFIX = "MATRIX"; // ASCII, used to prefix the binary format const MODE_VERIFY_OTHER_USER = 0x00; // Verifying someone who isn't us const MODE_VERIFY_SELF_TRUSTED = 0x01; // We trust the master key const MODE_VERIFY_SELF_UNTRUSTED = 0x02; // We do not trust the master key export class QRCodeData { constructor( mode, sharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey, buffer, ) { this._sharedSecret = sharedSecret; this._mode = mode; this._otherUserMasterKey = otherUserMasterKey; this._otherDeviceKey = otherDeviceKey; this._myMasterKey = myMasterKey; this._buffer = buffer; } static async create(request, client) { const sharedSecret = QRCodeData._generateSharedSecret(); const mode = QRCodeData._determineMode(request, client); let otherUserMasterKey = null; let otherDeviceKey = null; let myMasterKey = null; if (mode === MODE_VERIFY_OTHER_USER) { const otherUserCrossSigningInfo = client.getStoredCrossSigningForUser(request.otherUserId); otherUserMasterKey = otherUserCrossSigningInfo.getId("master"); } else if (mode === MODE_VERIFY_SELF_TRUSTED) { otherDeviceKey = await QRCodeData._getOtherDeviceKey(request, client); } else if (mode === MODE_VERIFY_SELF_UNTRUSTED) { const myUserId = client.getUserId(); const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId); myMasterKey = myCrossSigningInfo.getId("master"); } const qrData = QRCodeData._generateQrData( request, client, mode, sharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey, ); const buffer = QRCodeData._generateBuffer(qrData); return new QRCodeData(mode, sharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey, buffer); } get buffer() { return this._buffer; } get mode() { return this._mode; } /** * only set when mode is MODE_VERIFY_SELF_TRUSTED * @return {string} device key of other party at time of generating QR code */ get otherDeviceKey() { return this._otherDeviceKey; } /** * only set when mode is MODE_VERIFY_OTHER_USER * @return {string} master key of other party at time of generating QR code */ get otherUserMasterKey() { return this._otherUserMasterKey; } /** * only set when mode is MODE_VERIFY_SELF_UNTRUSTED * @return {string} own master key at time of generating QR code */ get myMasterKey() { return this._myMasterKey; } /** * The unpadded base64 encoded shared secret. */ get encodedSharedSecret() { return this._sharedSecret; } static _generateSharedSecret() { const secretBytes = new Uint8Array(11); global.crypto.getRandomValues(secretBytes); return encodeUnpaddedBase64(secretBytes); } static async _getOtherDeviceKey(request, client) { const myUserId = client.getUserId(); const otherDevice = request.targetDevice; const otherDeviceId = otherDevice ? otherDevice.deviceId : null; const device = client.getStoredDevice(myUserId, otherDeviceId); if (!device) { throw new Error("could not find device " + otherDeviceId); } const key = device.getFingerprint(); return key; } static _determineMode(request, client) { const myUserId = client.getUserId(); const otherUserId = request.otherUserId; let mode = MODE_VERIFY_OTHER_USER; if (myUserId === otherUserId) { // Mode changes depending on whether or not we trust the master cross signing key const myTrust = client.checkUserTrust(myUserId); if (myTrust.isCrossSigningVerified()) { mode = MODE_VERIFY_SELF_TRUSTED; } else { mode = MODE_VERIFY_SELF_UNTRUSTED; } } return mode; } static _generateQrData(request, client, mode, encodedSharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey, ) { const myUserId = client.getUserId(); const transactionId = request.channel.transactionId; const qrData = { prefix: BINARY_PREFIX, version: CODE_VERSION, mode, transactionId, firstKeyB64: '', // worked out shortly secondKeyB64: '', // worked out shortly secretB64: encodedSharedSecret, }; const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId); if (mode === MODE_VERIFY_OTHER_USER) { // First key is our master cross signing key qrData.firstKeyB64 = myCrossSigningInfo.getId("master"); // Second key is the other user's master cross signing key qrData.secondKeyB64 = otherUserMasterKey; } else if (mode === MODE_VERIFY_SELF_TRUSTED) { // First key is our master cross signing key qrData.firstKeyB64 = myCrossSigningInfo.getId("master"); qrData.secondKeyB64 = otherDeviceKey; } else if (mode === MODE_VERIFY_SELF_UNTRUSTED) { // First key is our device's key qrData.firstKeyB64 = client.getDeviceEd25519Key(); // Second key is what we think our master cross signing key is qrData.secondKeyB64 = myMasterKey; } return qrData; } static _generateBuffer(qrData) { let buf = Buffer.alloc(0); // we'll concat our way through life const appendByte = (b) => { const tmpBuf = Buffer.from([b]); buf = Buffer.concat([buf, tmpBuf]); }; const appendInt = (i) => { const tmpBuf = Buffer.alloc(2); tmpBuf.writeInt16BE(i, 0); buf = Buffer.concat([buf, tmpBuf]); }; const appendStr = (s, enc, withLengthPrefix = true) => { const tmpBuf = Buffer.from(s, enc); if (withLengthPrefix) appendInt(tmpBuf.byteLength); buf = Buffer.concat([buf, tmpBuf]); }; const appendEncBase64 = (b64) => { const b = decodeBase64(b64); const tmpBuf = Buffer.from(b); buf = Buffer.concat([buf, tmpBuf]); }; // Actually build the buffer for the QR code appendStr(qrData.prefix, "ascii", false); appendByte(qrData.version); appendByte(qrData.mode); appendStr(qrData.transactionId, "utf-8"); appendEncBase64(qrData.firstKeyB64); appendEncBase64(qrData.secondKeyB64); appendEncBase64(qrData.secretB64); return buf; } } matrix-js-sdk-9.11.0/src/crypto/verification/SAS.js000066400000000000000000000436241403504662500220720ustar00rootroot00000000000000/* 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 {VerificationBase as Base, SwitchStartEventError} from "./Base"; import anotherjson from 'another-json'; import { errorFactory, newInvalidMessageError, newKeyMismatchError, newUnknownMethodError, newUserCancelledError, } from './Error'; import {logger} from '../../logger'; const START_TYPE = "m.key.verification.start"; 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 ["🔒", "lock"], // 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", }; function calculateMAC(olmSAS, method) { return function(...args) { const macFunction = olmSAS[macMethods[method]]; const mac = macFunction.apply(olmSAS, args); logger.log("SAS calculateMAC:", method, args, mac); return mac; }; } 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._channel.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._channel.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 class SAS extends Base { static get NAME() { return "m.sas.v1"; } 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]); let retry = false; do { try { if (this.initiatedByMe) { return await this._doSendVerification(); } else { return await this._doRespondVerification(); } } catch (err) { if (err instanceof SwitchStartEventError) { // this changes what initiatedByMe returns this.startEvent = err.startEvent; retry = true; } else { throw err; } } } while (retry); } canSwitchStartEvent(event) { if (event.getType() !== START_TYPE) { return false; } const content = event.getContent(); return content && content.method === SAS.NAME && this._waitingForAccept; } async _sendStart() { const startContent = this._channel.completeContent(START_TYPE, { 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, }); await this._channel.sendCompleted(START_TYPE, startContent); return startContent; } async _doSendVerification() { this._waitingForAccept = true; let startContent; if (this.startEvent) { startContent = this._channel.completedContentFromEvent(this.startEvent); } else { startContent = await this._sendStart(); } // we might have switched to a different start event, // but was we didn't call _waitForEvent there was no // call that could throw yet. So check manually that // we're still on the initiator side if (!this.initiatedByMe) { throw new SwitchStartEventError(this.startEvent); } let e; try { e = await this._waitForEvent("m.key.verification.accept"); } finally { this._waitingForAccept = false; } 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(); await this._send("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(startContent); // 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.sasEvent = { sas: generateSas(sasBytes, sasMethods), confirm: async () => { try { await this._sendMAC(olmSAS, macMethod); resolve(); } catch (err) { reject(err); } }, cancel: () => reject(newUserCancelledError()), mismatch: () => reject(newMismatchedSASError()), }; this.emit("show_sas", this.sasEvent); }); [e] = await Promise.all([ this._waitForEvent("m.key.verification.mac") .then((e) => { // we don't expect any more messages from the other // party, and they may send a m.key.verification.done // when they're done on their end this._expectedEvent = "m.key.verification.done"; return e; }), verifySAS, ]); content = e.getContent(); await this._checkMAC(olmSAS, content, macMethod); } finally { olmSAS.free(); } } async _doRespondVerification() { // as m.related_to is not included in the encrypted content in e2e rooms, // we need to make sure it is added let content = this._channel.completedContentFromEvent(this.startEvent); // 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); await this._send("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(); await this._send("m.key.verification.key", { key: this.ourSASPubKey, }); const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6); const verifySAS = new Promise((resolve, reject) => { this.sasEvent = { sas: generateSas(sasBytes, sasMethods), confirm: async () => { try { await this._sendMAC(olmSAS, macMethod); resolve(); } catch (err) { reject(err); } }, cancel: () => reject(newUserCancelledError()), mismatch: () => reject(newMismatchedSASError()), }; this.emit("show_sas", this.sasEvent); }); [e] = await Promise.all([ this._waitForEvent("m.key.verification.mac") .then((e) => { // we don't expect any more messages from the other // party, and they may send a m.key.verification.done // when they're done on their end this._expectedEvent = "m.key.verification.done"; return e; }), verifySAS, ]); content = e.getContent(); await this._checkMAC(olmSAS, content, macMethod); } finally { olmSAS.free(); } } _sendMAC(olmSAS, method) { const mac = {}; const keyList = []; const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + this._baseApis.getUserId() + this._baseApis.deviceId + this.userId + this.deviceId + this._channel.transactionId; const deviceKeyId = `ed25519:${this._baseApis.deviceId}`; mac[deviceKeyId] = calculateMAC(olmSAS, method)( this._baseApis.getDeviceEd25519Key(), baseInfo + deviceKeyId, ); keyList.push(deviceKeyId); const crossSigningId = this._baseApis.getCrossSigningId(); if (crossSigningId) { const crossSigningKeyId = `ed25519:${crossSigningId}`; mac[crossSigningKeyId] = calculateMAC(olmSAS, method)( crossSigningId, baseInfo + crossSigningKeyId, ); keyList.push(crossSigningKeyId); } const keys = calculateMAC(olmSAS, method)( keyList.sort().join(","), baseInfo + "KEY_IDS", ); return this._send("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._channel.transactionId; if (content.keys !== calculateMAC(olmSAS, method)( Object.keys(content.mac).sort().join(","), baseInfo + "KEY_IDS", )) { throw newKeyMismatchError(); } await this._verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => { if (keyInfo !== calculateMAC(olmSAS, method)( device.keys[keyId], baseInfo + keyId, )) { throw newKeyMismatchError(); } }); } } matrix-js-sdk-9.11.0/src/crypto/verification/request/000077500000000000000000000000001403504662500225655ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/crypto/verification/request/InRoomChannel.js000066400000000000000000000317571403504662500256340ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { VerificationRequest, REQUEST_TYPE, READY_TYPE, START_TYPE, } from "./VerificationRequest"; import {logger} from '../../../logger'; const MESSAGE_TYPE = "m.room.message"; const M_REFERENCE = "m.reference"; const M_RELATES_TO = "m.relates_to"; /** * A key verification channel that sends verification events in the timeline of a room. * Uses the event id of the initial m.key.verification.request event as a transaction id. */ export class InRoomChannel { /** * @param {MatrixClient} client the matrix client, to send messages with and get current user & device from. * @param {string} roomId id of the room where verification events should be posted in, should be a DM with the given user. * @param {string} userId id of user that the verification request is directed at, should be present in the room. */ constructor(client, roomId, userId = null) { this._client = client; this._roomId = roomId; this.userId = userId; this._requestEventId = null; } get receiveStartFromOtherDevices() { return true; } get roomId() { return this._roomId; } /** The transaction id generated/used by this verification channel */ get transactionId() { return this._requestEventId; } static getOtherPartyUserId(event, client) { const type = InRoomChannel.getEventType(event); if (type !== REQUEST_TYPE) { return; } const ownUserId = client.getUserId(); const sender = event.getSender(); const content = event.getContent(); const receiver = content.to; if (sender === ownUserId) { return receiver; } else if (receiver === ownUserId) { return sender; } } /** * @param {MatrixEvent} event the event to get the timestamp of * @return {number} the timestamp when the event was sent */ getTimestamp(event) { return event.getTs(); } /** * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel * @param {string} type the event type to check * @returns {bool} boolean flag */ static canCreateRequest(type) { return type === REQUEST_TYPE; } /** * Extract the transaction id used by a given key verification event, if any * @param {MatrixEvent} event the event * @returns {string} the transaction id */ static getTransactionId(event) { if (InRoomChannel.getEventType(event) === REQUEST_TYPE) { return event.getId(); } else { const relation = event.getRelation(); if (relation && relation.rel_type === M_REFERENCE) { return relation.event_id; } } } /** * Checks whether this event is a well-formed key verification event. * This only does checks that don't rely on the current state of a potentially already channel * so we can prevent channels being created by invalid events. * `handleEvent` can do more checks and choose to ignore invalid events. * @param {MatrixEvent} event the event to validate * @param {MatrixClient} client the client to get the current user and device id from * @returns {bool} whether the event is valid and should be passed to handleEvent */ static validateEvent(event, client) { const txnId = InRoomChannel.getTransactionId(event); if (typeof txnId !== "string" || txnId.length === 0) { return false; } const type = InRoomChannel.getEventType(event); const content = event.getContent(); // from here on we're fairly sure that this is supposed to be // part of a verification request, so be noisy when rejecting something if (type === REQUEST_TYPE) { if (!content || typeof content.to !== "string" || !content.to.length) { logger.log("InRoomChannel: validateEvent: " + "no valid to " + (content && content.to)); return false; } // ignore requests that are not direct to or sent by the syncing user if (!InRoomChannel.getOtherPartyUserId(event, client)) { logger.log("InRoomChannel: validateEvent: " + `not directed to or sent by me: ${event.getSender()}` + `, ${content && content.to}`); return false; } } return VerificationRequest.validateEvent(type, event, client); } /** * As m.key.verification.request events are as m.room.message events with the InRoomChannel * to have a fallback message in non-supporting clients, we map the real event type * to the symbolic one to keep things in unison with ToDeviceChannel * @param {MatrixEvent} event the event to get the type of * @returns {string} the "symbolic" event type */ static getEventType(event) { const type = event.getType(); if (type === MESSAGE_TYPE) { const content = event.getContent(); if (content) { const {msgtype} = content; if (msgtype === REQUEST_TYPE) { return REQUEST_TYPE; } } } if (type && type !== REQUEST_TYPE) { return type; } else { return ""; } } /** * Changes the state of the channel, request, and verifier in response to a key verification event. * @param {MatrixEvent} event to handle * @param {VerificationRequest} request the request to forward handling to * @param {bool} isLiveEvent whether this is an even received through sync or not * @returns {Promise} a promise that resolves when any requests as an anwser to the passed-in event are sent. */ async handleEvent(event, request, isLiveEvent) { // prevent processing the same event multiple times, as under // some circumstances Room.timeline can get emitted twice for the same event if (request.hasEventId(event.getId())) { return; } const type = InRoomChannel.getEventType(event); // do validations that need state (roomId, userId), // ignore if invalid if (event.getRoomId() !== this._roomId) { return; } // set userId if not set already if (this.userId === null) { const userId = InRoomChannel.getOtherPartyUserId(event, this._client); if (userId) { this.userId = userId; } } // ignore events not sent by us or the other party const ownUserId = this._client.getUserId(); const sender = event.getSender(); if (this.userId !== null) { if (sender !== ownUserId && sender !== this.userId) { logger.log(`InRoomChannel: ignoring verification event from ` + `non-participating sender ${sender}`); return; } } if (this._requestEventId === null) { this._requestEventId = InRoomChannel.getTransactionId(event); } const isRemoteEcho = !!event.getUnsigned().transaction_id; const isSentByUs = event.getSender() === this._client.getUserId(); return await request.handleEvent( type, event, isLiveEvent, isRemoteEcho, isSentByUs); } /** * Adds the transaction id (relation) back to a received event * so it has the same format as returned by `completeContent` before sending. * The relation can not appear on the event content because of encryption, * relations are excluded from encryption. * @param {MatrixEvent} event the received event * @returns {Object} the content object with the relation added again */ completedContentFromEvent(event) { // ensure m.related_to is included in e2ee rooms // as the field is excluded from encryption const content = Object.assign({}, event.getContent()); content[M_RELATES_TO] = event.getRelation(); return content; } /** * Add all the fields to content needed for sending it over this channel. * This is public so verification methods (SAS uses this) can get the exact * content that will be sent independent of the used channel, * as they need to calculate the hash of it. * @param {string} type the event type * @param {object} content the (incomplete) content * @returns {object} the complete content, as it will be sent. */ completeContent(type, content) { content = Object.assign({}, content); if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { content.from_device = this._client.getDeviceId(); } if (type === REQUEST_TYPE) { // type is mapped to m.room.message in the send method content = { body: this._client.getUserId() + " is requesting to verify " + "your key, but your client does not support in-chat key " + "verification. You will need to use legacy key " + "verification to verify keys.", msgtype: REQUEST_TYPE, to: this.userId, from_device: content.from_device, methods: content.methods, }; } else { content[M_RELATES_TO] = { rel_type: M_REFERENCE, event_id: this.transactionId, }; } return content; } /** * Send an event over the channel with the content not having gone through `completeContent`. * @param {string} type the event type * @param {object} uncompletedContent the (incomplete) content * @returns {Promise} the promise of the request */ send(type, uncompletedContent) { const content = this.completeContent(type, uncompletedContent); return this.sendCompleted(type, content); } /** * Send an event over the channel with the content having gone through `completeContent` already. * @param {string} type the event type * @param {object} content * @returns {Promise} the promise of the request */ async sendCompleted(type, content) { let sendType = type; if (type === REQUEST_TYPE) { sendType = MESSAGE_TYPE; } const response = await this._client.sendEvent(this._roomId, sendType, content); if (type === REQUEST_TYPE) { this._requestEventId = response.event_id; } } } export class InRoomRequests { constructor() { this._requestsByRoomId = new Map(); } getRequest(event) { const roomId = event.getRoomId(); const txnId = InRoomChannel.getTransactionId(event); return this._getRequestByTxnId(roomId, txnId); } getRequestByChannel(channel) { return this._getRequestByTxnId(channel.roomId, channel.transactionId); } _getRequestByTxnId(roomId, txnId) { const requestsByTxnId = this._requestsByRoomId.get(roomId); if (requestsByTxnId) { return requestsByTxnId.get(txnId); } } setRequest(event, request) { this._setRequest( event.getRoomId(), InRoomChannel.getTransactionId(event), request, ); } setRequestByChannel(channel, request) { this._setRequest(channel.roomId, channel.transactionId, request); } _setRequest(roomId, txnId, request) { let requestsByTxnId = this._requestsByRoomId.get(roomId); if (!requestsByTxnId) { requestsByTxnId = new Map(); this._requestsByRoomId.set(roomId, requestsByTxnId); } requestsByTxnId.set(txnId, request); } removeRequest(event) { const roomId = event.getRoomId(); const requestsByTxnId = this._requestsByRoomId.get(roomId); if (requestsByTxnId) { requestsByTxnId.delete(InRoomChannel.getTransactionId(event)); if (requestsByTxnId.size === 0) { this._requestsByRoomId.delete(roomId); } } } findRequestInProgress(roomId) { const requestsByTxnId = this._requestsByRoomId.get(roomId); if (requestsByTxnId) { for (const request of requestsByTxnId.values()) { if (request.pending) { return request; } } } } } matrix-js-sdk-9.11.0/src/crypto/verification/request/ToDeviceChannel.js000066400000000000000000000320061403504662500261170ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {randomString} from '../../../randomstring'; import {logger} from '../../../logger'; import { CANCEL_TYPE, PHASE_STARTED, PHASE_READY, REQUEST_TYPE, READY_TYPE, START_TYPE, VerificationRequest, } from "./VerificationRequest"; import {errorFromEvent, newUnexpectedMessageError} from "../Error"; import {MatrixEvent} from "../../../models/event"; /** * A key verification channel that sends verification events over to_device messages. * Generates its own transaction ids. */ export class ToDeviceChannel { // userId and devices of user we're about to verify constructor(client, userId, devices, transactionId = null, deviceId = null) { this._client = client; this.userId = userId; this._devices = devices; this.transactionId = transactionId; this._deviceId = deviceId; } isToDevices(devices) { if (devices.length === this._devices.length) { for (const device of devices) { const d = this._devices.find(d => d.deviceId === device.deviceId); if (!d) { return false; } } return true; } else { return false; } } get deviceId() { return this._deviceId; } static getEventType(event) { return event.getType(); } /** * Extract the transaction id used by a given key verification event, if any * @param {MatrixEvent} event the event * @returns {string} the transaction id */ static getTransactionId(event) { const content = event.getContent(); return content && content.transaction_id; } /** * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel * @param {string} type the event type to check * @returns {bool} boolean flag */ static canCreateRequest(type) { return type === REQUEST_TYPE || type === START_TYPE; } /** * Checks whether this event is a well-formed key verification event. * This only does checks that don't rely on the current state of a potentially already channel * so we can prevent channels being created by invalid events. * `handleEvent` can do more checks and choose to ignore invalid events. * @param {MatrixEvent} event the event to validate * @param {MatrixClient} client the client to get the current user and device id from * @returns {bool} whether the event is valid and should be passed to handleEvent */ static validateEvent(event, client) { if (event.isCancelled()) { logger.warn("Ignoring flagged verification request from " + event.getSender()); return false; } const content = event.getContent(); if (!content) { logger.warn("ToDeviceChannel.validateEvent: invalid: no content"); return false; } if (!content.transaction_id) { logger.warn("ToDeviceChannel.validateEvent: invalid: no transaction_id"); return false; } const type = event.getType(); if (type === REQUEST_TYPE) { if (!Number.isFinite(content.timestamp)) { logger.warn("ToDeviceChannel.validateEvent: invalid: no timestamp"); return false; } if (event.getSender() === client.getUserId() && content.from_device == client.getDeviceId() ) { // ignore requests from ourselves, because it doesn't make sense for a // device to verify itself logger.warn("ToDeviceChannel.validateEvent: invalid: from own device"); return false; } } return VerificationRequest.validateEvent(type, event, client); } /** * @param {MatrixEvent} event the event to get the timestamp of * @return {number} the timestamp when the event was sent */ getTimestamp(event) { const content = event.getContent(); return content && content.timestamp; } /** * Changes the state of the channel, request, and verifier in response to a key verification event. * @param {MatrixEvent} event to handle * @param {VerificationRequest} request the request to forward handling to * @param {bool} isLiveEvent whether this is an even received through sync or not * @returns {Promise} a promise that resolves when any requests as an anwser to the passed-in event are sent. */ async handleEvent(event, request, isLiveEvent) { const type = event.getType(); const content = event.getContent(); if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { if (!this.transactionId) { this.transactionId = content.transaction_id; } const deviceId = content.from_device; // adopt deviceId if not set before and valid if (!this._deviceId && this._devices.includes(deviceId)) { this._deviceId = deviceId; } // if no device id or different from addopted one, cancel with sender if (!this._deviceId || this._deviceId !== deviceId) { // also check that message came from the device we sent the request to earlier on // and do send a cancel message to that device // (but don't cancel the request for the device we should be talking to) const cancelContent = this.completeContent(errorFromEvent(newUnexpectedMessageError())); return this._sendToDevices(CANCEL_TYPE, cancelContent, [deviceId]); } } const wasStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY; await request.handleEvent(event.getType(), event, isLiveEvent, false, false); const isStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY; const isAcceptingEvent = type === START_TYPE || type === READY_TYPE; // the request has picked a ready or start event, tell the other devices about it if (isAcceptingEvent && !wasStarted && isStarted && this._deviceId) { const nonChosenDevices = this._devices.filter( d => d !== this._deviceId && d !== this._client.getDeviceId(), ); if (nonChosenDevices.length) { const message = this.completeContent({ code: "m.accepted", reason: "Verification request accepted by another device", }); await this._sendToDevices(CANCEL_TYPE, message, nonChosenDevices); } } } /** * See {InRoomChannel.completedContentFromEvent} why this is needed. * @param {MatrixEvent} event the received event * @returns {Object} the content object */ completedContentFromEvent(event) { return event.getContent(); } /** * Add all the fields to content needed for sending it over this channel. * This is public so verification methods (SAS uses this) can get the exact * content that will be sent independent of the used channel, * as they need to calculate the hash of it. * @param {string} type the event type * @param {object} content the (incomplete) content * @returns {object} the complete content, as it will be sent. */ completeContent(type, content) { // make a copy content = Object.assign({}, content); if (this.transactionId) { content.transaction_id = this.transactionId; } if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { content.from_device = this._client.getDeviceId(); } if (type === REQUEST_TYPE) { content.timestamp = Date.now(); } return content; } /** * Send an event over the channel with the content not having gone through `completeContent`. * @param {string} type the event type * @param {object} uncompletedContent the (incomplete) content * @returns {Promise} the promise of the request */ send(type, uncompletedContent = {}) { // create transaction id when sending request if ((type === REQUEST_TYPE || type === START_TYPE) && !this.transactionId) { this.transactionId = ToDeviceChannel.makeTransactionId(); } const content = this.completeContent(type, uncompletedContent); return this.sendCompleted(type, content); } /** * Send an event over the channel with the content having gone through `completeContent` already. * @param {string} type the event type * @param {object} content * @returns {Promise} the promise of the request */ async sendCompleted(type, content) { let result; if (type === REQUEST_TYPE) { result = await this._sendToDevices(type, content, this._devices); } else { result = await this._sendToDevices(type, content, [this._deviceId]); } // the VerificationRequest state machine requires remote echos of the event // the client sends itself, so we fake this for to_device messages const remoteEchoEvent = new MatrixEvent({ sender: this._client.getUserId(), content, type, }); await this._request.handleEvent( type, remoteEchoEvent, /*isLiveEvent=*/true, /*isRemoteEcho=*/true, /*isSentByUs=*/true, ); return result; } _sendToDevices(type, content, devices) { if (devices.length) { const msgMap = {}; for (const deviceId of devices) { msgMap[deviceId] = content; } return this._client.sendToDevice(type, {[this.userId]: msgMap}); } else { return Promise.resolve(); } } /** * Allow Crypto module to create and know the transaction id before the .start event gets sent. * @returns {string} the transaction id */ static makeTransactionId() { return randomString(32); } } export class ToDeviceRequests { constructor() { this._requestsByUserId = new Map(); } getRequest(event) { return this.getRequestBySenderAndTxnId( event.getSender(), ToDeviceChannel.getTransactionId(event), ); } getRequestByChannel(channel) { return this.getRequestBySenderAndTxnId(channel.userId, channel.transactionId); } getRequestBySenderAndTxnId(sender, txnId) { const requestsByTxnId = this._requestsByUserId.get(sender); if (requestsByTxnId) { return requestsByTxnId.get(txnId); } } setRequest(event, request) { this.setRequestBySenderAndTxnId( event.getSender(), ToDeviceChannel.getTransactionId(event), request, ); } setRequestByChannel(channel, request) { this.setRequestBySenderAndTxnId(channel.userId, channel.transactionId, request); } setRequestBySenderAndTxnId(sender, txnId, request) { let requestsByTxnId = this._requestsByUserId.get(sender); if (!requestsByTxnId) { requestsByTxnId = new Map(); this._requestsByUserId.set(sender, requestsByTxnId); } requestsByTxnId.set(txnId, request); } removeRequest(event) { const userId = event.getSender(); const requestsByTxnId = this._requestsByUserId.get(userId); if (requestsByTxnId) { requestsByTxnId.delete(ToDeviceChannel.getTransactionId(event)); if (requestsByTxnId.size === 0) { this._requestsByUserId.delete(userId); } } } findRequestInProgress(userId, devices) { const requestsByTxnId = this._requestsByUserId.get(userId); if (requestsByTxnId) { for (const request of requestsByTxnId.values()) { if (request.pending && request.channel.isToDevices(devices)) { return request; } } } } getRequestsInProgress(userId) { const requestsByTxnId = this._requestsByUserId.get(userId); if (requestsByTxnId) { return Array.from(requestsByTxnId.values()).filter(r => r.pending); } return []; } } matrix-js-sdk-9.11.0/src/crypto/verification/request/VerificationRequest.js000066400000000000000000001025201403504662500271160ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {logger} from '../../../logger'; import {EventEmitter} from 'events'; import { errorFactory, errorFromEvent, newUnexpectedMessageError, newUnknownMethodError, } from "../Error"; import {QRCodeData, SCAN_QR_CODE_METHOD} from "../QRCode"; // How long after the event's timestamp that the request times out const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes // How long after we receive the event that the request times out const TIMEOUT_FROM_EVENT_RECEIPT = 2 * 60 * 1000; // 2 minutes // to avoid almost expired verification notifications // from showing a notification and almost immediately // disappearing, also ignore verification requests that // are this amount of time away from expiring. const VERIFICATION_REQUEST_MARGIN = 3 * 1000; // 3 seconds export const EVENT_PREFIX = "m.key.verification."; export const REQUEST_TYPE = EVENT_PREFIX + "request"; export const START_TYPE = EVENT_PREFIX + "start"; export const CANCEL_TYPE = EVENT_PREFIX + "cancel"; export const DONE_TYPE = EVENT_PREFIX + "done"; export const READY_TYPE = EVENT_PREFIX + "ready"; export const PHASE_UNSENT = 1; export const PHASE_REQUESTED = 2; export const PHASE_READY = 3; export const PHASE_STARTED = 4; export const PHASE_CANCELLED = 5; export const PHASE_DONE = 6; /** * State machine for verification requests. * Things that differ based on what channel is used to * send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`. * @event "change" whenever the state of the request object has changed. */ export class VerificationRequest extends EventEmitter { constructor(channel, verificationMethods, client) { super(); this.channel = channel; this.channel._request = this; this._verificationMethods = verificationMethods; this._client = client; this._commonMethods = []; this._setPhase(PHASE_UNSENT, false); this._eventsByUs = new Map(); this._eventsByThem = new Map(); this._observeOnly = false; this._timeoutTimer = null; this._accepting = false; this._declining = false; this._verifierHasFinished = false; this._cancelled = false; this._chosenMethod = null; // we keep a copy of the QR Code data (including other user master key) around // for QR reciprocate verification, to protect against // cross-signing identity reset between the .ready and .start event // and signing the wrong key after .start this._qrCodeData = null; // The timestamp when we received the request event from the other side this._requestReceivedAt = null; } /** * Stateless validation logic not specific to the channel. * Invoked by the same static method in either channel. * @param {string} type the "symbolic" event type, as returned by the `getEventType` function on the channel. * @param {MatrixEvent} event the event to validate. Don't call getType() on it but use the `type` parameter instead. * @param {MatrixClient} client the client to get the current user and device id from * @returns {bool} whether the event is valid and should be passed to handleEvent */ static validateEvent(type, event, client) { const content = event.getContent(); if (!type || !type.startsWith(EVENT_PREFIX)) { return false; } // from here on we're fairly sure that this is supposed to be // part of a verification request, so be noisy when rejecting something if (!content) { logger.log("VerificationRequest: validateEvent: no content"); return false; } if (type === REQUEST_TYPE || type === READY_TYPE) { if (!Array.isArray(content.methods)) { logger.log("VerificationRequest: validateEvent: " + "fail because methods"); return false; } } if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { if (typeof content.from_device !== "string" || content.from_device.length === 0 ) { logger.log("VerificationRequest: validateEvent: "+ "fail because from_device"); return false; } } return true; } get invalid() { return this.phase === PHASE_UNSENT; } /** returns whether the phase is PHASE_REQUESTED */ get requested() { return this.phase === PHASE_REQUESTED; } /** returns whether the phase is PHASE_CANCELLED */ get cancelled() { return this.phase === PHASE_CANCELLED; } /** returns whether the phase is PHASE_READY */ get ready() { return this.phase === PHASE_READY; } /** returns whether the phase is PHASE_STARTED */ get started() { return this.phase === PHASE_STARTED; } /** returns whether the phase is PHASE_DONE */ get done() { return this.phase === PHASE_DONE; } /** once the phase is PHASE_STARTED (and !initiatedByMe) or PHASE_READY: common methods supported by both sides */ get methods() { return this._commonMethods; } /** the method picked in the .start event */ get chosenMethod() { return this._chosenMethod; } calculateEventTimeout(event) { let effectiveExpiresAt = this.channel.getTimestamp(event) + TIMEOUT_FROM_EVENT_TS; if (this._requestReceivedAt && !this.initiatedByMe && this.phase <= PHASE_REQUESTED ) { const expiresAtByReceipt = this._requestReceivedAt + TIMEOUT_FROM_EVENT_RECEIPT; effectiveExpiresAt = Math.min(effectiveExpiresAt, expiresAtByReceipt); } return Math.max(0, effectiveExpiresAt - Date.now()); } /** The current remaining amount of ms before the request should be automatically cancelled */ get timeout() { const requestEvent = this._getEventByEither(REQUEST_TYPE); if (requestEvent) { return this.calculateEventTimeout(requestEvent); } return 0; } /** * The key verification request event. * @returns {MatrixEvent} The request event, or falsey if not found. */ get requestEvent() { return this._getEventByEither(REQUEST_TYPE); } /** current phase of the request. Some properties might only be defined in a current phase. */ get phase() { return this._phase; } /** The verifier to do the actual verification, once the method has been established. Only defined when the `phase` is PHASE_STARTED. */ get verifier() { return this._verifier; } get canAccept() { return this.phase < PHASE_READY && !this._accepting && !this._declining; } get accepting() { return this._accepting; } get declining() { return this._declining; } /** whether this request has sent it's initial event and needs more events to complete */ get pending() { return !this.observeOnly && this._phase !== PHASE_DONE && this._phase !== PHASE_CANCELLED; } /** Only set after a .ready if the other party can scan a QR code */ get qrCodeData() { return this._qrCodeData; } /** Checks whether the other party supports a given verification method. * This is useful when setting up the QR code UI, as it is somewhat asymmetrical: * if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa. * For methods that need to be supported by both ends, use the `methods` property. * @param {string} method the method to check * @param {boolean} force to check even if the phase is not ready or started yet, internal usage * @return {bool} whether or not the other party said the supported the method */ otherPartySupportsMethod(method, force = false) { if (!force && !this.ready && !this.started) { return false; } const theirMethodEvent = this._eventsByThem.get(REQUEST_TYPE) || this._eventsByThem.get(READY_TYPE); if (!theirMethodEvent) { // if we started straight away with .start event, // we are assuming that the other side will support the // chosen method, so return true for that. if (this.started && this.initiatedByMe) { const myStartEvent = this._eventsByUs.get(START_TYPE); const content = myStartEvent && myStartEvent.getContent(); const myStartMethod = content && content.method; return method == myStartMethod; } return false; } const content = theirMethodEvent.getContent(); if (!content) { return false; } const {methods} = content; if (!Array.isArray(methods)) { return false; } return methods.includes(method); } /** Whether this request was initiated by the syncing user. * For InRoomChannel, this is who sent the .request event. * For ToDeviceChannel, this is who sent the .start event */ get initiatedByMe() { // event created by us but no remote echo has been received yet const noEventsYet = (this._eventsByUs.size + this._eventsByThem.size) === 0; if (this._phase === PHASE_UNSENT && noEventsYet) { return true; } const hasMyRequest = this._eventsByUs.has(REQUEST_TYPE); const hasTheirRequest = this._eventsByThem.has(REQUEST_TYPE); if (hasMyRequest && !hasTheirRequest) { return true; } if (!hasMyRequest && hasTheirRequest) { return false; } const hasMyStart = this._eventsByUs.has(START_TYPE); const hasTheirStart = this._eventsByThem.has(START_TYPE); if (hasMyStart && !hasTheirStart) { return true; } return false; } /** The id of the user that initiated the request */ get requestingUserId() { if (this.initiatedByMe) { return this._client.getUserId(); } else { return this.otherUserId; } } /** The id of the user that (will) receive(d) the request */ get receivingUserId() { if (this.initiatedByMe) { return this.otherUserId; } else { return this._client.getUserId(); } } /** The user id of the other party in this request */ get otherUserId() { return this.channel.userId; } get isSelfVerification() { return this._client.getUserId() === this.otherUserId; } /** * The id of the user that cancelled the request, * only defined when phase is PHASE_CANCELLED */ get cancellingUserId() { const myCancel = this._eventsByUs.get(CANCEL_TYPE); const theirCancel = this._eventsByThem.get(CANCEL_TYPE); if (myCancel && (!theirCancel || myCancel.getId() < theirCancel.getId())) { return myCancel.getSender(); } if (theirCancel) { return theirCancel.getSender(); } return undefined; } /** * The cancellation code e.g m.user which is responsible for cancelling this verification */ get cancellationCode() { const ev = this._getEventByEither(CANCEL_TYPE); return ev ? ev.getContent().code : null; } get observeOnly() { return this._observeOnly; } /** * Gets which device the verification should be started with * given the events sent so far in the verification. This is the * same algorithm used to determine which device to send the * verification to when no specific device is specified. * @returns {{userId: *, deviceId: *}} The device information */ get targetDevice() { const theirFirstEvent = this._eventsByThem.get(REQUEST_TYPE) || this._eventsByThem.get(READY_TYPE) || this._eventsByThem.get(START_TYPE); const theirFirstContent = theirFirstEvent.getContent(); const fromDevice = theirFirstContent.from_device; return { userId: this.otherUserId, deviceId: fromDevice, }; } /* Start the key verification, creating a verifier and sending a .start event. * If no previous events have been sent, pass in `targetDevice` to set who to direct this request to. * @param {string} method the name of the verification method to use. * @param {string?} targetDevice.userId the id of the user to direct this request to * @param {string?} targetDevice.deviceId the id of the device to direct this request to * @returns {VerifierBase} the verifier of the given method */ beginKeyVerification(method, targetDevice = null) { // need to allow also when unsent in case of to_device if (!this.observeOnly && !this._verifier) { const validStartPhase = this.phase === PHASE_REQUESTED || this.phase === PHASE_READY || (this.phase === PHASE_UNSENT && this.channel.constructor.canCreateRequest(START_TYPE)); if (validStartPhase) { // when called on a request that was initiated with .request event // check the method is supported by both sides if (this._commonMethods.length && !this._commonMethods.includes(method)) { throw newUnknownMethodError(); } this._verifier = this._createVerifier(method, null, targetDevice); if (!this._verifier) { throw newUnknownMethodError(); } this._chosenMethod = method; } } return this._verifier; } /** * sends the initial .request event. * @returns {Promise} resolves when the event has been sent. */ async sendRequest() { if (!this.observeOnly && this._phase === PHASE_UNSENT) { const methods = [...this._verificationMethods.keys()]; await this.channel.send(REQUEST_TYPE, {methods}); } } /** * Cancels the request, sending a cancellation to the other party * @param {string?} error.reason the error reason to send the cancellation with * @param {string?} error.code the error code to send the cancellation with * @returns {Promise} resolves when the event has been sent. */ async cancel({reason = "User declined", code = "m.user"} = {}) { if (!this.observeOnly && this._phase !== PHASE_CANCELLED) { this._declining = true; this.emit("change"); if (this._verifier) { return this._verifier.cancel(errorFactory(code, reason)()); } else { this._cancellingUserId = this._client.getUserId(); await this.channel.send(CANCEL_TYPE, {code, reason}); } } } /** * Accepts the request, sending a .ready event to the other party * @returns {Promise} resolves when the event has been sent. */ async accept() { if (!this.observeOnly && this.phase === PHASE_REQUESTED && !this.initiatedByMe) { const methods = [...this._verificationMethods.keys()]; this._accepting = true; this.emit("change"); await this.channel.send(READY_TYPE, {methods}); } } /** * Can be used to listen for state changes until the callback returns true. * @param {Function} fn callback to evaluate whether the request is in the desired state. * Takes the request as an argument. * @returns {Promise} that resolves once the callback returns true * @throws {Error} when the request is cancelled */ waitFor(fn) { return new Promise((resolve, reject) => { const check = () => { let handled = false; if (fn(this)) { resolve(this); handled = true; } else if (this.cancelled) { reject(new Error("cancelled")); handled = true; } if (handled) { this.off("change", check); } return handled; }; if (!check()) { this.on("change", check); } }); } _setPhase(phase, notify = true) { this._phase = phase; if (notify) { this.emit("change"); } } _getEventByEither(type) { return this._eventsByThem.get(type) || this._eventsByUs.get(type); } _getEventBy(type, byThem) { if (byThem) { return this._eventsByThem.get(type); } else { return this._eventsByUs.get(type); } } _calculatePhaseTransitions() { const transitions = [{phase: PHASE_UNSENT}]; const phase = () => transitions[transitions.length - 1].phase; // always pass by .request first to be sure channel.userId has been set const hasRequestByThem = this._eventsByThem.has(REQUEST_TYPE); const requestEvent = this._getEventBy(REQUEST_TYPE, hasRequestByThem); if (requestEvent) { transitions.push({phase: PHASE_REQUESTED, event: requestEvent}); } const readyEvent = requestEvent && this._getEventBy(READY_TYPE, !hasRequestByThem); if (readyEvent && phase() === PHASE_REQUESTED) { transitions.push({phase: PHASE_READY, event: readyEvent}); } let startEvent; if (readyEvent || !requestEvent) { const theirStartEvent = this._eventsByThem.get(START_TYPE); const ourStartEvent = this._eventsByUs.get(START_TYPE); // any party can send .start after a .ready or unsent if (theirStartEvent && ourStartEvent) { startEvent = theirStartEvent.getSender() < ourStartEvent.getSender() ? theirStartEvent : ourStartEvent; } else { startEvent = theirStartEvent ? theirStartEvent : ourStartEvent; } } else { startEvent = this._getEventBy(START_TYPE, !hasRequestByThem); } if (startEvent) { const fromRequestPhase = phase() === PHASE_REQUESTED && requestEvent.getSender() !== startEvent.getSender(); const fromUnsentPhase = phase() === PHASE_UNSENT && this.channel.constructor.canCreateRequest(START_TYPE); if (fromRequestPhase || phase() === PHASE_READY || fromUnsentPhase) { transitions.push({phase: PHASE_STARTED, event: startEvent}); } } const ourDoneEvent = this._eventsByUs.get(DONE_TYPE); if (this._verifierHasFinished || (ourDoneEvent && phase() === PHASE_STARTED)) { transitions.push({phase: PHASE_DONE}); } const cancelEvent = this._getEventByEither(CANCEL_TYPE); if ((this._cancelled || cancelEvent) && phase() !== PHASE_DONE) { transitions.push({phase: PHASE_CANCELLED, event: cancelEvent}); return transitions; } return transitions; } _transitionToPhase(transition) { const {phase, event} = transition; // get common methods if (phase === PHASE_REQUESTED || phase === PHASE_READY) { if (!this._wasSentByOwnDevice(event)) { const content = event.getContent(); this._commonMethods = content.methods.filter(m => this._verificationMethods.has(m)); } } // detect if we're not a party in the request, and we should just observe if (!this.observeOnly) { // if requested or accepted by one of my other devices if (phase === PHASE_REQUESTED || phase === PHASE_STARTED || phase === PHASE_READY ) { if ( this.channel.receiveStartFromOtherDevices && this._wasSentByOwnUser(event) && !this._wasSentByOwnDevice(event) ) { this._observeOnly = true; } } } // create verifier if (phase === PHASE_STARTED) { const {method} = event.getContent(); if (!this._verifier && !this.observeOnly) { this._verifier = this._createVerifier(method, event); if (!this._verifier) { this.cancel({ code: "m.unknown_method", reason: `Unknown method: ${method}`, }); } else { this._chosenMethod = method; } } } } _applyPhaseTransitions() { const transitions = this._calculatePhaseTransitions(); const existingIdx = transitions.findIndex(t => t.phase === this.phase); // trim off phases we already went through, if any const newTransitions = transitions.slice(existingIdx + 1); // transition to all new phases for (const transition of newTransitions) { this._transitionToPhase(transition); } return newTransitions; } _isWinningStartRace(newEvent) { if (newEvent.getType() !== START_TYPE) { return false; } const oldEvent = this._verifier.startEvent; let oldRaceIdentifier; if (this.isSelfVerification) { // if the verifier does not have a startEvent, // it is because it's still sending and we are on the initator side // we know we are sending a .start event because we already // have a verifier (checked in calling method) if (oldEvent) { const oldContent = oldEvent.getContent(); oldRaceIdentifier = oldContent && oldContent.from_device; } else { oldRaceIdentifier = this._client.getDeviceId(); } } else { if (oldEvent) { oldRaceIdentifier = oldEvent.getSender(); } else { oldRaceIdentifier = this._client.getUserId(); } } let newRaceIdentifier; if (this.isSelfVerification) { const newContent = newEvent.getContent(); newRaceIdentifier = newContent && newContent.from_device; } else { newRaceIdentifier = newEvent.getSender(); } return newRaceIdentifier < oldRaceIdentifier; } hasEventId(eventId) { for (const event of this._eventsByUs.values()) { if (event.getId() === eventId) { return true; } } for (const event of this._eventsByThem.values()) { if (event.getId() === eventId) { return true; } } return false; } /** * Changes the state of the request and verifier in response to a key verification event. * @param {string} type the "symbolic" event type, as returned by the `getEventType` function on the channel. * @param {MatrixEvent} event the event to handle. Don't call getType() on it but use the `type` parameter instead. * @param {bool} isLiveEvent whether this is an even received through sync or not * @param {bool} isRemoteEcho whether this is the remote echo of an event sent by the same device * @param {bool} isSentByUs whether this event is sent by a party that can accept and/or observe the request like one of our peers. * For InRoomChannel this means any device for the syncing user. For ToDeviceChannel, just the syncing device. * @returns {Promise} a promise that resolves when any requests as an anwser to the passed-in event are sent. */ async handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs) { // if reached phase cancelled or done, ignore anything else that comes if (this.done || this.cancelled) { return; } const wasObserveOnly = this._observeOnly; this._adjustObserveOnly(event, isLiveEvent); if (!this.observeOnly && !isRemoteEcho) { if (await this._cancelOnError(type, event)) { return; } } // This assumes verification won't need to send an event with // the same type for the same party twice. // This is true for QR and SAS verification, and was // added here to prevent verification getting cancelled // when the server duplicates an event (https://github.com/matrix-org/synapse/issues/3365) const isDuplicateEvent = isSentByUs ? this._eventsByUs.has(type) : this._eventsByThem.has(type); if (isDuplicateEvent) { return; } const oldPhase = this.phase; this._addEvent(type, event, isSentByUs); // this will create if needed the verifier so needs to happen before calling it const newTransitions = this._applyPhaseTransitions(); try { // only pass events from the other side to the verifier, // no remote echos of our own events if (this._verifier && !this.observeOnly) { const newEventWinsRace = this._isWinningStartRace(event); if (this._verifier.canSwitchStartEvent(event) && newEventWinsRace) { this._verifier.switchStartEvent(event); } else if (!isRemoteEcho) { if (type === CANCEL_TYPE || (this._verifier.events && this._verifier.events.includes(type))) { this._verifier.handleEvent(event); } } } if (newTransitions.length) { // create QRCodeData if the other side can scan // important this happens before emitting a phase change, // so listeners can rely on it being there already // We only do this for live events because it is important that // we sign the keys that were in the QR code, and not the keys // we happen to have at some later point in time. if (isLiveEvent && newTransitions.some(t => t.phase === PHASE_READY)) { const shouldGenerateQrCode = this.otherPartySupportsMethod(SCAN_QR_CODE_METHOD, true); if (shouldGenerateQrCode) { this._qrCodeData = await QRCodeData.create(this, this._client); } } const lastTransition = newTransitions[newTransitions.length - 1]; const {phase} = lastTransition; this._setupTimeout(phase); // set phase as last thing as this emits the "change" event this._setPhase(phase); } else if (this._observeOnly !== wasObserveOnly) { this.emit("change"); } } finally { // log events we processed so we can see from rageshakes what events were added to a request logger.log(`Verification request ${this.channel.transactionId}: ` + `${type} event with id:${event.getId()}, ` + `content:${JSON.stringify(event.getContent())} ` + `deviceId:${this.channel.deviceId}, ` + `sender:${event.getSender()}, isSentByUs:${isSentByUs}, ` + `isLiveEvent:${isLiveEvent}, isRemoteEcho:${isRemoteEcho}, ` + `phase:${oldPhase}=>${this.phase}, ` + `observeOnly:${wasObserveOnly}=>${this._observeOnly}`); } } _setupTimeout(phase) { const shouldTimeout = !this._timeoutTimer && !this.observeOnly && phase === PHASE_REQUESTED; if (shouldTimeout) { this._timeoutTimer = setTimeout(this._cancelOnTimeout, this.timeout); } if (this._timeoutTimer) { const shouldClear = phase === PHASE_STARTED || phase === PHASE_READY || phase === PHASE_DONE || phase === PHASE_CANCELLED; if (shouldClear) { clearTimeout(this._timeoutTimer); this._timeoutTimer = null; } } } _cancelOnTimeout = () => { try { if (this.initiatedByMe) { this.cancel({ reason: "Other party didn't accept in time", code: "m.timeout", }); } else { this.cancel({ reason: "User didn't accept in time", code: "m.timeout", }); } } catch (err) { logger.error("Error while cancelling verification request", err); } }; async _cancelOnError(type, event) { if (type === START_TYPE) { const method = event.getContent().method; if (!this._verificationMethods.has(method)) { await this.cancel(errorFromEvent(newUnknownMethodError())); return true; } } const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT; const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED; // only if phase has passed from PHASE_UNSENT should we cancel, because events // are allowed to come in in any order (at least with InRoomChannel). So we only know // we're dealing with a valid request we should participate in once we've moved to PHASE_REQUESTED // before that, we could be looking at somebody elses verification request and we just // happen to be in the room if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) { logger.warn(`Cancelling, unexpected ${type} verification ` + `event from ${event.getSender()}`); const reason = `Unexpected ${type} event in phase ${this.phase}`; await this.cancel(errorFromEvent(newUnexpectedMessageError({reason}))); return true; } return false; } _adjustObserveOnly(event, isLiveEvent) { // don't send out events for historical requests if (!isLiveEvent) { this._observeOnly = true; } if (this.calculateEventTimeout(event) < VERIFICATION_REQUEST_MARGIN) { this._observeOnly = true; } } _addEvent(type, event, isSentByUs) { if (isSentByUs) { this._eventsByUs.set(type, event); } else { this._eventsByThem.set(type, event); } // once we know the userId of the other party (from the .request event) // see if any event by anyone else crept into this._eventsByThem if (type === REQUEST_TYPE) { for (const [type, event] of this._eventsByThem.entries()) { if (event.getSender() !== this.otherUserId) { this._eventsByThem.delete(type); } } // also remember when we received the request event this._requestReceivedAt = Date.now(); } } _createVerifier(method, startEvent = null, targetDevice = null) { if (!targetDevice) { targetDevice = this.targetDevice; } const {userId, deviceId} = targetDevice; const VerifierCtor = this._verificationMethods.get(method); if (!VerifierCtor) { logger.warn("could not find verifier constructor for method", method); return; } return new VerifierCtor( this.channel, this._client, userId, deviceId, startEvent, this, ); } _wasSentByOwnUser(event) { return event.getSender() === this._client.getUserId(); } // only for .request, .ready or .start _wasSentByOwnDevice(event) { if (!this._wasSentByOwnUser(event)) { return false; } const content = event.getContent(); if (!content || content.from_device !== this._client.getDeviceId()) { return false; } return true; } onVerifierCancelled() { this._cancelled = true; // move to cancelled phase const newTransitions = this._applyPhaseTransitions(); if (newTransitions.length) { this._setPhase(newTransitions[newTransitions.length - 1].phase); } } onVerifierFinished() { this.channel.send("m.key.verification.done", {}); this._verifierHasFinished = true; // move to .done phase const newTransitions = this._applyPhaseTransitions(); if (newTransitions.length) { this._setPhase(newTransitions[newTransitions.length - 1].phase); } } getEventFromOtherParty(type) { return this._eventsByThem.get(type); } } matrix-js-sdk-9.11.0/src/errors.js000066400000000000000000000031501403504662500167440ustar00rootroot00000000000000// 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); export class KeySignatureUploadError extends Error { constructor(message, value) { super(message); this.value = value; } } matrix-js-sdk-9.11.0/src/filter-component.js000066400000000000000000000115101403504662500207140ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module filter-component */ /** * Checks if a value matches a given field value, which may be a * terminated * wildcard pattern. * @param {String} actual_value The value to be compared * @param {String} filter_value The filter pattern to be compared * @return {bool} true if the actual_value matches the filter_value */ function _matches_wildcard(actual_value, filter_value) { if (filter_value.endsWith("*")) { const type_prefix = filter_value.slice(0, -1); return actual_value.substr(0, type_prefix.length) === type_prefix; } else { return actual_value === filter_value; } } /** * FilterComponent is a section of a Filter definition which defines the * types, rooms, senders filters etc to be applied to a particular type of resource. * This is all ported over from synapse's Filter object. * * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as * 'Filters' are referred to as 'FilterCollections'. * * @constructor * @param {Object} filter_json the definition of this filter JSON, e.g. { 'contains_url': true } */ export function FilterComponent(filter_json) { this.filter_json = filter_json; this.types = filter_json.types || null; this.not_types = filter_json.not_types || []; this.rooms = filter_json.rooms || null; this.not_rooms = filter_json.not_rooms || []; this.senders = filter_json.senders || null; this.not_senders = filter_json.not_senders || []; this.contains_url = filter_json.contains_url || null; } /** * Checks with the filter component matches the given event * @param {MatrixEvent} event event to be checked against the filter * @return {bool} true if the event matches the filter */ FilterComponent.prototype.check = function(event) { return this._checkFields( event.getRoomId(), event.getSender(), event.getType(), event.getContent() ? event.getContent().url !== undefined : false, ); }; /** * Checks whether the filter component matches the given event fields. * @param {String} room_id the room_id for the event being checked * @param {String} sender the sender of the event being checked * @param {String} event_type the type of the event being checked * @param {String} contains_url whether the event contains a content.url field * @return {bool} true if the event fields match the filter */ FilterComponent.prototype._checkFields = function(room_id, sender, event_type, contains_url) { const literal_keys = { "rooms": function(v) { return room_id === v; }, "senders": function(v) { return sender === v; }, "types": function(v) { return _matches_wildcard(event_type, v); }, }; const self = this; for (let n=0; n < Object.keys(literal_keys).length; n++) { const name = Object.keys(literal_keys)[n]; const match_func = literal_keys[name]; const not_name = "not_" + name; const disallowed_values = self[not_name]; if (disallowed_values.filter(match_func).length > 0) { return false; } const allowed_values = self[name]; if (allowed_values && allowed_values.length > 0) { const anyMatch = allowed_values.some(match_func); if (!anyMatch) { return false; } } } const contains_url_filter = this.filter_json.contains_url; if (contains_url_filter !== undefined) { if (contains_url_filter !== contains_url) { return false; } } return true; }; /** * Filters a list of events down to those which match this filter component * @param {MatrixEvent[]} events Events to be checked againt the filter component * @return {MatrixEvent[]} events which matched the filter component */ FilterComponent.prototype.filter = function(events) { return events.filter(this.check, this); }; /** * Returns the limit field for a given filter component, providing a default of * 10 if none is otherwise specified. Cargo-culted from Synapse. * @return {Number} the limit for this filter component. */ FilterComponent.prototype.limit = function() { return this.filter_json.limit !== undefined ? this.filter_json.limit : 10; }; matrix-js-sdk-9.11.0/src/filter.js000066400000000000000000000136351403504662500167260ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module filter */ import {FilterComponent} from "./filter-component"; /** * @param {Object} obj * @param {string} keyNesting * @param {*} val */ function setProp(obj, keyNesting, val) { const nestedKeys = keyNesting.split("."); let currentObj = obj; for (let i = 0; i < (nestedKeys.length - 1); i++) { if (!currentObj[nestedKeys[i]]) { currentObj[nestedKeys[i]] = {}; } currentObj = currentObj[nestedKeys[i]]; } currentObj[nestedKeys[nestedKeys.length - 1]] = val; } /** * Construct a new Filter. * @constructor * @param {string} userId The user ID for this filter. * @param {string=} filterId The filter ID if known. * @prop {string} userId The user ID of the filter * @prop {?string} filterId The filter ID */ export function Filter(userId, filterId) { this.userId = userId; this.filterId = filterId; this.definition = {}; } Filter.LAZY_LOADING_MESSAGES_FILTER = { lazy_load_members: true, }; /** * Get the ID of this filter on your homeserver (if known) * @return {?Number} The filter ID */ Filter.prototype.getFilterId = function() { return this.filterId; }; /** * Get the JSON body of the filter. * @return {Object} The filter definition */ Filter.prototype.getDefinition = function() { return this.definition; }; /** * Set the JSON body of the filter * @param {Object} definition The filter definition */ Filter.prototype.setDefinition = function(definition) { this.definition = definition; // This is all ported from synapse's FilterCollection() // definitions look something like: // { // "room": { // "rooms": ["!abcde:example.com"], // "not_rooms": ["!123456:example.com"], // "state": { // "types": ["m.room.*"], // "not_rooms": ["!726s6s6q:example.com"], // "lazy_load_members": true, // }, // "timeline": { // "limit": 10, // "types": ["m.room.message"], // "not_rooms": ["!726s6s6q:example.com"], // "not_senders": ["@spam:example.com"] // "contains_url": true // }, // "ephemeral": { // "types": ["m.receipt", "m.typing"], // "not_rooms": ["!726s6s6q:example.com"], // "not_senders": ["@spam:example.com"] // } // }, // "presence": { // "types": ["m.presence"], // "not_senders": ["@alice:example.com"] // }, // "event_format": "client", // "event_fields": ["type", "content", "sender"] // } const room_filter_json = definition.room; // consider the top level rooms/not_rooms filter const room_filter_fields = {}; if (room_filter_json) { if (room_filter_json.rooms) { room_filter_fields.rooms = room_filter_json.rooms; } if (room_filter_json.rooms) { room_filter_fields.not_rooms = room_filter_json.not_rooms; } this._include_leave = room_filter_json.include_leave || false; } this._room_filter = new FilterComponent(room_filter_fields); this._room_timeline_filter = new FilterComponent( room_filter_json ? (room_filter_json.timeline || {}) : {}, ); // don't bother porting this from synapse yet: // this._room_state_filter = // new FilterComponent(room_filter_json.state || {}); // this._room_ephemeral_filter = // new FilterComponent(room_filter_json.ephemeral || {}); // this._room_account_data_filter = // new FilterComponent(room_filter_json.account_data || {}); // this._presence_filter = // new FilterComponent(definition.presence || {}); // this._account_data_filter = // new FilterComponent(definition.account_data || {}); }; /** * Get the room.timeline filter component of the filter * @return {FilterComponent} room timeline filter component */ Filter.prototype.getRoomTimelineFilterComponent = function() { return this._room_timeline_filter; }; /** * Filter the list of events based on whether they are allowed in a timeline * based on this filter * @param {MatrixEvent[]} events the list of events being filtered * @return {MatrixEvent[]} the list of events which match the filter */ Filter.prototype.filterRoomTimeline = function(events) { return this._room_timeline_filter.filter(this._room_filter.filter(events)); }; /** * Set the max number of events to return for each room's timeline. * @param {Number} limit The max number of events to return for each room. */ Filter.prototype.setTimelineLimit = function(limit) { setProp(this.definition, "room.timeline.limit", limit); }; Filter.prototype.setLazyLoadMembers = function(enabled) { setProp(this.definition, "room.state.lazy_load_members", !!enabled); }; /** * Control whether left rooms should be included in responses. * @param {boolean} includeLeave True to make rooms the user has left appear * in responses. */ Filter.prototype.setIncludeLeaveRooms = function(includeLeave) { setProp(this.definition, "room.include_leave", includeLeave); }; /** * Create a filter from existing data. * @static * @param {string} userId * @param {string} filterId * @param {Object} jsonObj * @return {Filter} */ Filter.fromJson = function(userId, filterId, jsonObj) { const filter = new Filter(userId, filterId); filter.setDefinition(jsonObj); return filter; }; matrix-js-sdk-9.11.0/src/http-api.js000066400000000000000000001064071403504662500171670ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This is an internal module. See {@link MatrixHttpApi} for the public class. * @module http-api */ import {parse as parseContentType} from "content-type"; import * as utils from "./utils"; import {logger} from './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. import * as callbacks from "./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. */ export const PREFIX_R0 = "/_matrix/client/r0"; /** * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs. */ export const PREFIX_UNSTABLE = "/_matrix/client/unstable"; /** * URI path for v1 of the the identity API * @deprecated Use v2. */ export const PREFIX_IDENTITY_V1 = "/_matrix/identity/api/v1"; /** * URI path for the v2 identity API */ export const PREFIX_IDENTITY_V2 = "/_matrix/identity/v2"; /** * URI path for the media repo API */ export const 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. */ export 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 = []; } MatrixHttpApi.prototype = { /** * Sets the baase URL for the identity server * @param {string} url The new base url */ setIdBaseUrl: function(url) { this.opts.idBaseUrl = url; }, /** * 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 {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 = utils.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() { let resp; switch (xhr.readyState) { case global.XMLHttpRequest.DONE: callbacks.clearTimeout(xhr.timeout_timer); try { if (xhr.status === 0) { throw new AbortError(); } 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, accessToken, ) { if (!this.opts.idBaseUrl) { throw new Error("No Identity Server base URL set"); } 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: true, // we want a JSON response if we can _matrix_opts: this.opts, headers: {}, }; if (method === 'GET') { opts.qs = params; } else if (typeof params === "object") { opts.json = params; } if (accessToken) { opts.headers['Authorization'] = `Bearer ${accessToken}`; } const defer = utils.defer(); this.opts.request( opts, requestCallback(defer, callback, this.opts.onlyData), ); return defer.promise; }, /** * 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 {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 {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 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 {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 {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) { queryParams = { ...queryParams, ...this.opts.extraParams, }; } 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 = utils.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 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) { // the unit tests use matrix-mock-request, which throw the string "aborted" when aborting a request. // See https://github.com/matrix-org/matrix-mock-request/blob/3276d0263a561b5b8326b47bae720578a2c7473a/src/index.js#L48 const aborted = err.name === "AbortError" || err === "aborted"; if (!aborted && !(err instanceof MatrixError)) { // browser-request just throws normal Error objects, // not `TypeError`s like fetch does. So just assume any // error is due to the connection. err = new ConnectionError("request failed", err); } } 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') { const jsonBody = typeof(body) === 'object' ? body : JSON.parse(body); err = new MatrixError(jsonBody); } 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 */ export class MatrixError extends Error { constructor(errorJson) { errorJson = errorJson || {}; super(`MatrixError: ${errorJson.errcode}`); this.errcode = errorJson.errcode; this.name = errorJson.errcode || "Unknown error code"; this.message = errorJson.error || "Unknown message"; this.data = errorJson; } } /** * Construct a ConnectionError. This is a JavaScript Error indicating * that a request failed because of some error with the connection, either * CORS was not correctly configured on the server, the server didn't response, * the request timed out, or the internet connection on the client side went down. * @constructor */ export class ConnectionError extends Error { constructor(message, cause = undefined) { super(message + (cause ? `: ${cause.message}` : "")); this._cause = cause; } get name() { return "ConnectionError"; } get cause() { return this._cause; } } export class AbortError extends Error { constructor() { super("Operation aborted"); } get name() { return "AbortError"; } } /** * Retries a network operation run in a callback. * @param {number} maxAttempts maximum attempts to try * @param {Function} callback callback that returns a promise of the network operation. If rejected with ConnectionError, it will be retried by calling the callback again. * @return {any} the result of the network operation * @throws {ConnectionError} If after maxAttempts the callback still throws ConnectionError */ export async function retryNetworkOperation(maxAttempts, callback) { let attempts = 0; let lastConnectionError = null; while (attempts < maxAttempts) { try { if (attempts > 0) { const timeout = 1000 * Math.pow(2, attempts); logger.log(`network operation failed ${attempts} times,` + ` retrying in ${timeout}ms...`); await new Promise(r => setTimeout(r, timeout)); } return await callback(); } catch (err) { if (err instanceof ConnectionError) { attempts += 1; lastConnectionError = err; } else { throw err; } } } throw lastConnectionError; } matrix-js-sdk-9.11.0/src/index.ts000066400000000000000000000017571403504662500165640ustar00rootroot00000000000000/* 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. */ import * as matrixcs from "./matrix"; import * as utils from "./utils"; import request from "request"; matrixcs.request(request); utils.runPolyfills(); try { // eslint-disable-next-line @typescript-eslint/no-var-requires const crypto = require('crypto'); utils.setCrypto(crypto); } catch (err) { console.log('nodejs was compiled without crypto support'); } export * from "./matrix"; export default matrixcs; matrix-js-sdk-9.11.0/src/indexeddb-helpers.js000066400000000000000000000037211403504662500210220ustar00rootroot00000000000000/* 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. */ /** * 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); }); } matrix-js-sdk-9.11.0/src/indexeddb-worker.js000066400000000000000000000015461403504662500206740ustar00rootroot00000000000000/* 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. */ /** * Separate exports file for the indexeddb web worker, which is designed * to be used separately */ /** The {@link module:indexeddb-store-worker~IndexedDBStoreWorker} class. */ export {IndexedDBStoreWorker} from "./store/indexeddb-store-worker"; matrix-js-sdk-9.11.0/src/interactive-auth.js000066400000000000000000000512751403504662500207170ustar00rootroot00000000000000/* 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. */ /** @module interactive-auth */ import url from "url"; import * as utils from "./utils"; import {logger} from './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?): 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): 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. * */ export 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 {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; const hasFlows = this._data && this._data.flows; // if we have no flows, try a request to acquire the flows if (!hasFlows) { if (this._busyChangedCallback) this._busyChangedCallback(true); // use the existing sessionid, if one is present. let auth = null; if (this._data.session) { auth = { session: this._data.session, }; } this._doRequest(auth).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; // likewise don't poll if there is no auth session in progress if (!this._resolveFunc) 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 creds = { sid: this._emailSid, client_secret: this._clientSecret, }; if (await this._matrixClient.doesServerRequireIdServerParam()) { const idServerParsedUrl = url.parse( this._matrixClient.getIdentityServerUrl(), ); creds.id_server = idServerParsedUrl.host; } authDict = { type: EMAIL_STAGE_TYPE, // TODO: Remove `threepid_creds` once servers support proper UIA // See https://github.com/matrix-org/synapse/issues/5665 // See https://github.com/matrix-org/matrix-doc/issues/2220 threepid_creds: creds, threepidCreds: creds, }; } } 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, if one is present. let auth; if (this._data.session) { auth = { session: this._data.session, }; utils.extend(auth, authData); } else { 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); this._resolveFunc = null; this._rejectFunc = null; } catch (error) { // sometimes UI auth errors don't come with flows const errorFlows = error.data ? error.data.flows : null; const haveFlows = 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; try { this._startNextAuthStage(); } catch (e) { this._rejectFunc(e); this._resolveFunc = null; this._rejectFunc = null; } 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, for whatever reason. this._rejectFunc(e); this._resolveFunc = null; this._rejectFunc = null; } 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 && 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; } } }, }; matrix-js-sdk-9.11.0/src/logger.ts000066400000000000000000000063321403504662500167260ustar00rootroot00000000000000/* Copyright 2018 André Jaenisch Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module logger */ import log, { Logger } from "loglevel"; // This is to demonstrate, that you can use any namespace you want. // Namespaces allow you to turn on/off the logging for specific parts of the // 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_NAMESPACE = "matrix"; // because rageshakes in react-sdk hijack the console log, also at module load time, // initializing the logger here races with the initialization of rageshakes. // to avoid the issue, we override the methodFactory of loglevel that binds to the // console methods at initialization time by a factory that looks up the console methods // when logging so we always get the current value of console methods. log.methodFactory = function(methodName, logLevel, loggerName) { return function(...args) { /* eslint-disable babel/no-invalid-this */ if (this.prefix) { args.unshift(this.prefix); } /* eslint-enable babel/no-invalid-this */ const supportedByConsole = methodName === "error" || methodName === "warn" || methodName === "trace" || methodName === "info"; /* eslint-disable no-console */ if (supportedByConsole) { return console[methodName](...args); } else { return console.log(...args); } /* eslint-enable no-console */ }; }; /** * Drop-in replacement for console using {@link https://www.npmjs.com/package/loglevel|loglevel}. * Can be tailored down to specific use cases if needed. */ export const logger: PrefixedLogger = log.getLogger(DEFAULT_NAMESPACE); logger.setLevel(log.levels.DEBUG); interface PrefixedLogger extends Logger { withPrefix?: (prefix: string) => PrefixedLogger; prefix?: string; } function extendLogger(logger: PrefixedLogger) { logger.withPrefix = function(prefix: string): PrefixedLogger { const existingPrefix = this.prefix || ""; return getPrefixedLogger(existingPrefix + prefix); }; } extendLogger(logger); function getPrefixedLogger(prefix): PrefixedLogger { const prefixLogger: PrefixedLogger = log.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`); if (prefixLogger.prefix !== prefix) { // Only do this setup work the first time through, as loggers are saved by name. extendLogger(prefixLogger); prefixLogger.prefix = prefix; prefixLogger.setLevel(log.levels.DEBUG); } return prefixLogger; } matrix-js-sdk-9.11.0/src/matrix.ts000066400000000000000000000230221403504662500167460ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import type Request from "request"; import {MemoryCryptoStore} from "./crypto/store/memory-crypto-store"; import {LocalStorageCryptoStore} from "./crypto/store/localStorage-crypto-store"; import {IndexedDBCryptoStore} from "./crypto/store/indexeddb-crypto-store"; import {MemoryStore} from "./store/memory"; import {StubStore} from "./store/stub"; import {LocalIndexedDBStoreBackend} from "./store/indexeddb-local-backend"; import {RemoteIndexedDBStoreBackend} from "./store/indexeddb-remote-backend"; import {MatrixScheduler} from "./scheduler"; import {MatrixClient} from "./client"; export * from "./client"; export * from "./http-api"; export * from "./autodiscovery"; export * from "./sync-accumulator"; export * from "./errors"; export * from "./models/event"; export * from "./models/room"; export * from "./models/group"; export * from "./models/event-timeline"; export * from "./models/event-timeline-set"; export * from "./models/room-member"; export * from "./models/room-state"; export * from "./models/user"; export * from "./scheduler"; export * from "./filter"; export * from "./timeline-window"; export * from "./interactive-auth"; export * from "./service-types"; export * from "./store/memory"; export * from "./store/indexeddb"; export * from "./store/session/webstorage"; export * from "./crypto/store/memory-crypto-store"; export * from "./crypto/store/indexeddb-crypto-store"; export * from "./content-repo"; export * as ContentHelpers from "./content-helpers"; export { createNewMatrixCall, setAudioOutput as setMatrixCallAudioOutput, setAudioInput as setMatrixCallAudioInput, setVideoInput as setMatrixCallVideoInput, } from "./webrtc/call"; // expose the underlying request object so different environments can use // different request libs (e.g. request or browser-request) let requestInstance; /** * 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. */ export function request(r) { requestInstance = r; } /** * Return the currently-set request function. * @return {requestFunction} The current request function. */ export function getRequest() { return requestInstance; } /** * 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. */ export function wrapRequest(wrapper) { const origRequest = requestInstance; requestInstance = function(options, callback) { return wrapper(origRequest, options, callback); }; } type Store = StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; type CryptoStore = MemoryCryptoStore | LocalStorageCryptoStore | IndexedDBCryptoStore; let cryptoStoreFactory = () => new 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}. */ export function setCryptoStoreFactory(fac) { cryptoStoreFactory = fac; } export interface ICreateClientOpts { baseUrl: string; idBaseUrl?: string; store?: Store; cryptoStore?: CryptoStore; scheduler?: MatrixScheduler; request?: Request; userId?: string; deviceId?: string; accessToken?: string; identityServer?: any; localTimeoutMs?: number; useAuthorizationHeader?: boolean; timelineSupport?: boolean; queryParams?: Record; deviceToImport?: { olmDevice: { pickledAccount: string; sessions: Array>; pickleKey: string; }; userId: string; deviceId: string; }; pickleKey?: string; sessionStore?: any; unstableClientRelationAggregation?: boolean; verificationMethods?: Array; forceTURN?: boolean; iceCandidatePoolSize?: number, supportsCallTransfer?: boolean, fallbackICEServerAllowed?: boolean; cryptoCallbacks?: ICryptoCallbacks; } export interface ICryptoCallbacks { getCrossSigningKey?: (keyType: string, pubKey: Uint8Array) => Promise; saveCrossSigningKeys?: (keys: Record) => void; shouldUpgradeDeviceVerifications?: ( users: Record ) => Promise; getSecretStorageKey?: ( keys: {keys: Record}, name: string ) => Promise<[string, Uint8Array] | null>; cacheSecretStorageKey?: ( keyId: string, keyInfo: ISecretStorageKeyInfo, key: Uint8Array ) => void; onSecretRequested?: ( userId: string, deviceId: string, requestId: string, secretName: string, deviceTrust: IDeviceTrustLevel ) => Promise; getDehydrationKey?: ( keyInfo: ISecretStorageKeyInfo, checkFunc: (Uint8Array) => void, ) => Promise; } // TODO: Move this to `SecretStorage` once converted export interface ISecretStorageKeyInfo { passphrase?: { algorithm: "m.pbkdf2"; iterations: number; salt: string; }; iv?: string; mac?: string; } // TODO: Move this to `CrossSigning` once converted export interface IDeviceTrustLevel { isVerified(): boolean; isCrossSigningVerified(): boolean; isLocallyVerified(): boolean; isTofu(): boolean; } /** * Construct a Matrix Client. Similar to {@link module:client.MatrixClient} * except that the 'request', 'store' and 'scheduler' dependencies are satisfied. * @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. */ export function createClient(opts: ICreateClientOpts | string) { if (typeof opts === "string") { opts = { "baseUrl": opts as string, }; } opts.request = opts.request || requestInstance; opts.store = opts.store || new MemoryStore({ localStorage: global.localStorage, }); opts.scheduler = opts.scheduler || new MatrixScheduler(); opts.cryptoStore = opts.cryptoStore || cryptoStoreFactory(); return new 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. */ matrix-js-sdk-9.11.0/src/models/000077500000000000000000000000001403504662500163565ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/models/event-context.js000066400000000000000000000062271403504662500215260ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module models/event-context */ /** * Construct a new EventContext * * An eventcontext is used for circumstances such as search results, when we * have a particular event of interest, and a bunch of events before and after * it. * * It also stores pagination tokens for going backwards and forwards in the * timeline. * * @param {MatrixEvent} ourEvent the event at the centre of this context * * @constructor */ export function EventContext(ourEvent) { this._timeline = [ourEvent]; this._ourEventIndex = 0; this._paginateTokens = {b: null, f: null}; // this is used by MatrixClient to keep track of active requests this._paginateRequests = {b: null, f: null}; } /** * Get the main event of interest * * This is a convenience function for getTimeline()[getOurEventIndex()]. * * @return {MatrixEvent} The event at the centre of this context. */ EventContext.prototype.getEvent = function() { return this._timeline[this._ourEventIndex]; }; /** * Get the list of events in this context * * @return {Array} An array of MatrixEvents */ EventContext.prototype.getTimeline = function() { return this._timeline; }; /** * Get the index in the timeline of our event * * @return {Number} */ EventContext.prototype.getOurEventIndex = function() { return this._ourEventIndex; }; /** * Get a pagination token. * * @param {boolean} backwards true to get the pagination token for going * backwards in time * @return {string} */ EventContext.prototype.getPaginateToken = function(backwards) { return this._paginateTokens[backwards ? 'b' : 'f']; }; /** * Set a pagination token. * * Generally this will be used only by the matrix js sdk. * * @param {string} token pagination token * @param {boolean} backwards true to set the pagination token for going * backwards in time */ EventContext.prototype.setPaginateToken = function(token, backwards) { this._paginateTokens[backwards ? 'b' : 'f'] = token; }; /** * Add more events to the timeline * * @param {Array} events new events, in timeline order * @param {boolean} atStart true to insert new events at the start */ EventContext.prototype.addEvents = function(events, atStart) { // TODO: should we share logic with Room.addEventsToTimeline? // Should Room even use EventContext? if (atStart) { this._timeline = events.concat(this._timeline); this._ourEventIndex += events.length; } else { this._timeline = this._timeline.concat(events); } }; matrix-js-sdk-9.11.0/src/models/event-timeline-set.js000066400000000000000000000743501403504662500224430ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module models/event-timeline-set */ import {EventEmitter} from "events"; import {EventTimeline} from "./event-timeline"; import {EventStatus} from "./event"; import * as utils from "../utils"; import {logger} from '../logger'; import {Relations} from './relations'; // var DEBUG = false; const DEBUG = true; let debuglog; if (DEBUG) { // using bind means that we get to keep useful line numbers in the console debuglog = logger.log.bind(logger); } else { debuglog = function() {}; } /** * Construct a set of EventTimeline objects, typically on behalf of a given * room. A room may have multiple EventTimelineSets for different levels * of filtering. The global notification list is also an EventTimelineSet, but * lacks a room. * *

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

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

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

    This is used when /sync returns a 'limited' timeline. * * @param {string=} backPaginationToken token for back-paginating the new timeline * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, * if absent or null, all timelines are reset. * * @fires module:client~MatrixClient#event:"Room.timelineReset" */ EventTimelineSet.prototype.resetLiveTimeline = function( backPaginationToken, forwardPaginationToken, ) { // Each EventTimeline has RoomState objects tracking the state at the start // and end of that timeline. The copies at the end of the live timeline are // special because they will have listeners attached to monitor changes to // the current room state, so we move this RoomState from the end of the // current live timeline to the end of the new one and, if necessary, // replace it with a newly created one. We also make a copy for the start // of the new timeline. // if timeline support is disabled, forget about the old timelines const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken; const oldTimeline = this._liveTimeline; const newTimeline = resetAllTimelines ? oldTimeline.forkLive(EventTimeline.FORWARDS) : oldTimeline.fork(EventTimeline.FORWARDS); if (resetAllTimelines) { this._timelines = [newTimeline]; this._eventIdToTimeline = {}; } else { this._timelines.push(newTimeline); } if (forwardPaginationToken) { // Now set the forward pagination token on the old live timeline // so it can be forward-paginated. oldTimeline.setPaginationToken( forwardPaginationToken, EventTimeline.FORWARDS, ); } // make sure we set the pagination token before firing timelineReset, // otherwise clients which start back-paginating will fail, and then get // stuck without realising that they *can* back-paginate. newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); // Now we can swap the live timeline to the new one. this._liveTimeline = newTimeline; this.emit("Room.timelineReset", this.room, this, resetAllTimelines); }; /** * Get the timeline which contains the given event, if any * * @param {string} eventId event ID to look for * @return {?module:models/event-timeline~EventTimeline} timeline containing * the given event, or null if unknown */ EventTimelineSet.prototype.getTimelineForEvent = function(eventId) { const res = this._eventIdToTimeline[eventId]; return (res === undefined) ? null : res; }; /** * Get an event which is stored in our timelines * * @param {string} eventId event ID to look for * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown */ EventTimelineSet.prototype.findEventById = function(eventId) { const tl = this.getTimelineForEvent(eventId); if (!tl) { return undefined; } return 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[eventId]; if (!existingTimeline) { // we don't know about this event yet. Just add it to the timeline. this.addEventToTimeline(event, timeline, toStartOfTimeline); lastEventWasNew = true; didUpdate = true; continue; } lastEventWasNew = false; if (existingTimeline == timeline) { debuglog("Event " + eventId + " already in timeline " + timeline); continue; } const neighbour = timeline.getNeighbouringTimeline(direction); if (neighbour) { // this timeline already has a neighbour in the relevant direction; // let's assume the timelines are already correctly linked up, and // skip over to it. // // there's probably some edge-case here where we end up with an // event which is in a timeline a way down the chain, and there is // a break in the chain somewhere. But I can't really imagine how // that would happen, so I'm going to ignore it for now. // if (existingTimeline == neighbour) { debuglog("Event " + eventId + " in neighbouring timeline - " + "switching to " + existingTimeline); } else { debuglog("Event " + eventId + " already in a different " + "timeline " + existingTimeline); } timeline = existingTimeline; continue; } // time to join the timelines. logger.info("Already have timeline for " + eventId + " - joining timeline " + timeline + " to " + existingTimeline); // Variables to keep the line length limited below. const existingIsLive = existingTimeline === this._liveTimeline; const timelineIsLive = timeline === this._liveTimeline; const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive; const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive; if (backwardsIsLive || forwardsIsLive) { // The live timeline should never be spliced into a non-live position. // We use independent logging to better discover the problem at a glance. if (backwardsIsLive) { logger.warn( "Refusing to set a preceding existingTimeLine on our " + "timeline as the existingTimeLine is live (" + existingTimeline + ")", ); } if (forwardsIsLive) { logger.warn( "Refusing to set our preceding timeline on a existingTimeLine " + "as our timeline is live (" + timeline + ")", ); } continue; // abort splicing - try next event } timeline.setNeighbouringTimeline(existingTimeline, direction); existingTimeline.setNeighbouringTimeline(timeline, inverseDirection); timeline = existingTimeline; didUpdate = true; } // see above - if the last event was new to us, or if we didn't find any // new information, we update the pagination token for whatever // timeline we ended up on. if (lastEventWasNew || !didUpdate) { if (direction === EventTimeline.FORWARDS && timeline === this._liveTimeline) { logger.warn({lastEventWasNew, didUpdate}); // for debugging logger.warn( `Refusing to set forwards pagination token of live timeline ` + `${timeline} to ${paginationToken}`, ); return; } timeline.setPaginationToken(paginationToken, direction); } }; /** * Add an event to the end of this live timeline. * * @param {MatrixEvent} event Event to be added * @param {string?} duplicateStrategy 'ignore' or 'replace' * @param {boolean} fromCache whether the sync response came from cache */ EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy, fromCache) { if (this._filter) { const events = this._filter.filterRoomTimeline([event]); if (!events.length) { return; } } const timeline = this._eventIdToTimeline[event.getId()]; if (timeline) { if (duplicateStrategy === "replace") { debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + event.getId()); const tlEvents = timeline.getEvents(); for (let j = 0; j < tlEvents.length; j++) { if (tlEvents[j].getId() === event.getId()) { // still need to set the right metadata on this event EventTimeline.setEventMetadata( event, timeline.getState(EventTimeline.FORWARDS), false, ); if (!tlEvents[j].encryptedType) { tlEvents[j] = event; } // XXX: we need to fire an event when this happens. break; } } } else { debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + event.getId()); } return; } this.addEventToTimeline(event, this._liveTimeline, false, fromCache); }; /** * Add event to the given timeline, and emit Room.timeline. Assumes * we have already checked we don't know about this event. * * Will fire "Room.timeline" for each event added. * * @param {MatrixEvent} event * @param {EventTimeline} timeline * @param {boolean} toStartOfTimeline * @param {boolean} fromCache whether the sync response came from cache * * @fires module:client~MatrixClient#event:"Room.timeline" */ EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, toStartOfTimeline, fromCache) { const eventId = event.getId(); timeline.addEvent(event, toStartOfTimeline); this._eventIdToTimeline[eventId] = timeline; this.setRelationsTarget(event); this.aggregateRelations(event); const data = { timeline: timeline, liveEvent: !toStartOfTimeline && timeline == this._liveTimeline && !fromCache, }; this.emit("Room.timeline", event, this.room, Boolean(toStartOfTimeline), false, data); }; /** * Replaces event with ID oldEventId with one with newEventId, if oldEventId is * recognised. Otherwise, add to the live timeline. Used to handle remote echos. * * @param {MatrixEvent} localEvent the new event to be added to the timeline * @param {String} oldEventId the ID of the original event * @param {boolean} newEventId the ID of the replacement event * * @fires module:client~MatrixClient#event:"Room.timeline" */ EventTimelineSet.prototype.handleRemoteEcho = function(localEvent, oldEventId, newEventId) { // XXX: why don't we infer newEventId from localEvent? const existingTimeline = this._eventIdToTimeline[oldEventId]; if (existingTimeline) { delete this._eventIdToTimeline[oldEventId]; this._eventIdToTimeline[newEventId] = existingTimeline; } else { if (this._filter) { if (this._filter.filterRoomTimeline([localEvent]).length) { this.addEventToTimeline(localEvent, this._liveTimeline, false); } } else { this.addEventToTimeline(localEvent, this._liveTimeline, false); } } }; /** * Removes a single event from this room. * * @param {String} eventId The id of the event to remove * * @return {?MatrixEvent} the removed event, or null if the event was not found * in this room. */ EventTimelineSet.prototype.removeEvent = function(eventId) { const timeline = this._eventIdToTimeline[eventId]; if (!timeline) { return null; } const removed = timeline.removeEvent(eventId); if (removed) { delete this._eventIdToTimeline[eventId]; const data = { timeline: timeline, }; this.emit("Room.timeline", removed, this.room, undefined, true, data); } return removed; }; /** * Determine where two events appear in the timeline relative to one another * * @param {string} eventId1 The id of the first event * @param {string} eventId2 The id of the second event * @return {?number} a number less than zero if eventId1 precedes eventId2, and * greater than zero if eventId1 succeeds eventId2. zero if they are the * same event; null if we can't tell (either because we don't know about one * of the events, or because they are in separate timelines which don't join * up). */ EventTimelineSet.prototype.compareEventOrdering = function(eventId1, eventId2) { if (eventId1 == eventId2) { // optimise this case return 0; } const timeline1 = this._eventIdToTimeline[eventId1]; const timeline2 = this._eventIdToTimeline[eventId2]; if (timeline1 === undefined) { return null; } if (timeline2 === undefined) { return null; } if (timeline1 === timeline2) { // both events are in the same timeline - figure out their // relative indices let idx1; let idx2; const events = timeline1.getEvents(); for (let idx = 0; idx < events.length && (idx1 === undefined || idx2 === undefined); idx++) { const evId = events[idx].getId(); if (evId == eventId1) { idx1 = idx; } if (evId == eventId2) { idx2 = idx; } } return idx1 - idx2; } // the events are in different timelines. Iterate through the // linkedlist to see which comes first. // first work forwards from timeline1 let tl = timeline1; while (tl) { if (tl === timeline2) { // timeline1 is before timeline2 return -1; } tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS); } // now try backwards from timeline1 tl = timeline1; while (tl) { if (tl === timeline2) { // timeline2 is before timeline1 return 1; } tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS); } // the timelines are not contiguous. return null; }; /** * Get a collection of relations to a given event in this timeline set. * * @param {String} eventId * The ID of the event that you'd like to access relation events for. * For example, with annotations, this would be the ID of the event being annotated. * @param {String} relationType * The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc. * @param {String} eventType * The relation event's type, such as "m.reaction", etc. * @throws If eventId, relationType or eventType * are not valid. * * @returns {?Relations} * A container for relation events or undefined if there are no relation events for * the relationType. */ EventTimelineSet.prototype.getRelationsForEvent = function( eventId, relationType, eventType, ) { if (!this._unstableClientRelationAggregation) { throw new Error("Client-side relation aggregation is disabled"); } if (!eventId || !relationType || !eventType) { throw new Error("Invalid arguments for `getRelationsForEvent`"); } // debuglog("Getting relations for: ", eventId, relationType, eventType); const relationsForEvent = this._relations[eventId] || {}; const relationsWithRelType = relationsForEvent[relationType] || {}; return relationsWithRelType[eventType]; }; /** * Set an event as the target event if any Relations exist for it already * * @param {MatrixEvent} event * The event to check as relation target. */ EventTimelineSet.prototype.setRelationsTarget = function(event) { if (!this._unstableClientRelationAggregation) { return; } const relationsForEvent = this._relations[event.getId()]; if (!relationsForEvent) { return; } // 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[relatesToEventId]; if (!relationsForEvent) { relationsForEvent = this._relations[relatesToEventId] = {}; } let relationsWithRelType = relationsForEvent[relationType]; if (!relationsWithRelType) { relationsWithRelType = relationsForEvent[relationType] = {}; } let relationsWithEventType = relationsWithRelType[eventType]; let isNewRelations = false; let relatesToEvent; if (!relationsWithEventType) { relationsWithEventType = relationsWithRelType[eventType] = new Relations( relationType, eventType, this.room, ); isNewRelations = true; relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId); if (relatesToEvent) { relationsWithEventType.setTargetEvent(relatesToEvent); } } relationsWithEventType.addEvent(event); // only emit once event has been added to relations if (isNewRelations && relatesToEvent) { relatesToEvent.emit("Event.relationsCreated", relationType, eventType); } }; /** * Fires whenever the timeline in a room is updated. * @event module:client~MatrixClient#"Room.timeline" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {?Room} room The room, if any, whose timeline was updated. * @param {boolean} toStartOfTimeline True if this event was added to the start * @param {boolean} removed True if this event has just been removed from the timeline * (beginning; oldest) of the timeline e.g. due to pagination. * * @param {object} data more data about the event * * @param {module:models/event-timeline.EventTimeline} data.timeline the timeline the * event was added to/removed from * * @param {boolean} data.liveEvent true if the event was a real-time event * added to the end of the live timeline * * @example * matrixClient.on("Room.timeline", * function(event, room, toStartOfTimeline, removed, data) { * if (!toStartOfTimeline && data.liveEvent) { * var messageToAppend = room.timeline.[room.timeline.length - 1]; * } * }); */ /** * Fires whenever the live timeline in a room is reset. * * When we get a 'limited' sync (for example, after a network outage), we reset * the live timeline to be empty before adding the recent events to the new * timeline. This event is fired after the timeline is reset, and before the * new events are added. * * @event module:client~MatrixClient#"Room.timelineReset" * @param {Room} room The room whose live timeline was reset, if any * @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset * @param {boolean} resetAllTimelines True if all timelines were reset. */ matrix-js-sdk-9.11.0/src/models/event-timeline.js000066400000000000000000000331631403504662500216470ustar00rootroot00000000000000/* Copyright 2016, 2017 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module models/event-timeline */ import {RoomState} from "./room-state"; /** * Construct a new EventTimeline * *

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

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

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

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

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

    This is an index which is incremented when events are prepended to the * timeline. An individual event therefore stays at the same index in the array * relative to the base index (although note that a given event's index may * well be less than the base index, thus giving that event a negative relative * index). * * @return {number} */ EventTimeline.prototype.getBaseIndex = function() { return this._baseIndex; }; /** * Get the list of events in this context * * @return {MatrixEvent[]} An array of MatrixEvents */ EventTimeline.prototype.getEvents = function() { return this._events; }; /** * Get the room state at the start/end of the timeline * * @param {string} direction EventTimeline.BACKWARDS to get the state at the * start of the timeline; EventTimeline.FORWARDS to get the state at the end * of the timeline. * * @return {RoomState} state at the start/end of the timeline */ EventTimeline.prototype.getState = function(direction) { if (direction == EventTimeline.BACKWARDS) { return this._startState; } else if (direction == EventTimeline.FORWARDS) { return this._endState; } else { throw new Error("Invalid direction '" + direction + "'"); } }; /** * Get a pagination token * * @param {string} direction EventTimeline.BACKWARDS to get the pagination * token for going backwards in time; EventTimeline.FORWARDS to get the * pagination token for going forwards in time. * * @return {?string} pagination token */ EventTimeline.prototype.getPaginationToken = function(direction) { return this.getState(direction).paginationToken; }; /** * Set a pagination token * * @param {?string} token pagination token * * @param {string} direction EventTimeline.BACKWARDS to set the pagination * token for going backwards in time; EventTimeline.FORWARDS to set the * pagination token for going forwards in time. */ EventTimeline.prototype.setPaginationToken = function(token, direction) { this.getState(direction).paginationToken = token; }; /** * Get the next timeline in the series * * @param {string} direction EventTimeline.BACKWARDS to get the previous * timeline; EventTimeline.FORWARDS to get the next timeline. * * @return {?EventTimeline} previous or following timeline, if they have been * joined up. */ EventTimeline.prototype.getNeighbouringTimeline = function(direction) { if (direction == EventTimeline.BACKWARDS) { return this._prevTimeline; } else if (direction == EventTimeline.FORWARDS) { return this._nextTimeline; } else { throw new Error("Invalid direction '" + direction + "'"); } }; /** * Set the next timeline in the series * * @param {EventTimeline} neighbour previous/following timeline * * @param {string} direction EventTimeline.BACKWARDS to set the previous * timeline; EventTimeline.FORWARDS to set the next timeline. * * @throws {Error} if an attempt is made to set the neighbouring timeline when * it is already set. */ EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) { if (this.getNeighbouringTimeline(direction)) { throw new Error("timeline already has a neighbouring timeline - " + "cannot reset neighbour (direction: " + direction + ")"); } if (direction == EventTimeline.BACKWARDS) { this._prevTimeline = neighbour; } else if (direction == EventTimeline.FORWARDS) { this._nextTimeline = neighbour; } else { throw new Error("Invalid direction '" + direction + "'"); } // make sure we don't try to paginate this timeline this.setPaginationToken(null, direction); }; /** * Add a new event to the timeline, and update the state * * @param {MatrixEvent} event new event * @param {boolean} atStart true to insert new event at the start */ EventTimeline.prototype.addEvent = function(event, atStart) { const stateContext = atStart ? this._startState : this._endState; // only call setEventMetadata on the unfiltered timelineSets const timelineSet = this.getTimelineSet(); if (timelineSet.room && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) { EventTimeline.setEventMetadata(event, stateContext, atStart); // modify state if (event.isState()) { stateContext.setStateEvents([event]); // it is possible that the act of setting the state event means we // can set more metadata (specifically sender/target props), so try // it again if the prop wasn't previously set. It may also mean that // the sender/target is updated (if the event set was a room member event) // so we want to use the *updated* member (new avatar/name) instead. // // However, we do NOT want to do this on member events if we're going // back in time, else we'll set the .sender value for BEFORE the given // member event, whereas we want to set the .sender value for the ACTUAL // member event itself. if (!event.sender || (event.getType() === "m.room.member" && !atStart)) { EventTimeline.setEventMetadata(event, stateContext, atStart); } } } let insertIndex; if (atStart) { insertIndex = 0; } else { insertIndex = this._events.length; } this._events.splice(insertIndex, 0, event); // insert element if (atStart) { this._baseIndex++; } }; /** * Static helper method to set sender and target properties * * @param {MatrixEvent} event the event whose metadata is to be set * @param {RoomState} stateContext the room state to be queried * @param {bool} toStartOfTimeline if true the event's forwardLooking flag is set false */ EventTimeline.setEventMetadata = function(event, stateContext, toStartOfTimeline) { // set sender and target properties event.sender = stateContext.getSentinelMember( event.getSender(), ); if (event.getType() === "m.room.member") { event.target = stateContext.getSentinelMember( event.getStateKey(), ); } if (event.isState()) { // room state has no concept of 'old' or 'current', but we want the // room state to regress back to previous values if toStartOfTimeline // is set, which means inspecting prev_content if it exists. This // is done by toggling the forwardLooking flag. if (toStartOfTimeline) { event.forwardLooking = false; } } }; /** * Remove an event from the timeline * * @param {string} eventId ID of event to be removed * @return {?MatrixEvent} removed event, or null if not found */ EventTimeline.prototype.removeEvent = function(eventId) { for (let i = this._events.length - 1; i >= 0; i--) { const ev = this._events[i]; if (ev.getId() == eventId) { this._events.splice(i, 1); if (i < this._baseIndex) { this._baseIndex--; } return ev; } } return null; }; /** * Return a string to identify this timeline, for debugging * * @return {string} name for this timeline */ EventTimeline.prototype.toString = function() { return this._name; }; matrix-js-sdk-9.11.0/src/models/event.js000066400000000000000000001147171403504662500200500ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This is an internal module. See {@link MatrixEvent} and {@link RoomEvent} for * the public classes. * @module models/event */ import {EventEmitter} from 'events'; import * as utils from '../utils'; import {logger} from '../logger'; /** * Enum for event statuses. * @readonly * @enum {string} */ export 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", }; const interns = {}; function intern(str) { if (!interns[str]) { interns[str] = str; } return interns[str]; } /** * Construct a Matrix Event object * @constructor * * @param {Object} event The raw event to be wrapped in this DAO * * @prop {Object} event The raw (possibly encrypted) event. Do not access * this property directly unless you absolutely have to. Prefer the getter * methods defined on this class. Using the getter methods shields your app * from changes to event JSON between Matrix versions. * * @prop {RoomMember} sender The room member who sent this event, or null e.g. * this is a presence event. This is only guaranteed to be set for events that * appear in a timeline, ie. do not guarantee that it will be set on state * events. * @prop {RoomMember} target The room member who is the target of this event, e.g. * the invitee, the person being banned, etc. * @prop {EventStatus} status The sending status of the event. * @prop {Error} error most recent error associated with sending the event, if any * @prop {boolean} forwardLooking True if this event is 'forward looking', meaning * that getDirectionalContent() will return event.content and not event.prev_content. * Default: true. This property is experimental and may change. */ export const MatrixEvent = function( event, ) { // intern the values of matrix events to force share strings and reduce the // amount of needless string duplication. This can save moderate amounts of // memory (~10% on a 350MB heap). // 'membership' at the event level (rather than the content level) is a legacy // field that Element never otherwise looks at, but it will still take up a lot // of space if we don't intern it. ["state_key", "type", "sender", "room_id", "membership"].forEach((prop) => { if (!event[prop]) { return; } event[prop] = intern(event[prop]); }); ["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 = []; /* where the decryption key is untrusted */ this._untrusted = null; /* 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; /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event, * `Crypto` will set this the `VerificationRequest` for the event * so it can be easily accessed from the timeline. */ this.verificationRequest = null; /* The txnId with which this event was sent if it was during this session, allows for a unique ID which does not change when the event comes back down sync. */ this._txnId = null; /* Set an approximate timestamp for the event relative the local clock. * This will inherently be approximate because it doesn't take into account * the time between the server putting the 'age' field on the event as it sent * it to us and the time we're now constructing this event, but that's better * than assuming the local clock is in sync with the origin HS's clock. */ this._localTimestamp = Date.now() - this.getAge(); }; utils.inherits(MatrixEvent, EventEmitter); utils.extend(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 age of the event when this function was called. * This is the 'age' field adjusted according to how long this client has * had the event. * @return {Number} The age of this event in milliseconds. */ getLocalAge: function() { return Date.now() - this._localTimestamp; }, /** * Get the event state_key if it has one. This will return undefined * for message events. * @return {string} The event's state_key. */ getStateKey: function() { 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 * @param {bool} isRetry True if this is a retry (enables more logging) * * @returns {Promise} promise which resolves (to undefined) when the decryption * attempt is completed. */ attemptDecryption: async function(crypto, isRetry) { // 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 decrypted", ); } // 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, isRetry); 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, isRetry) { // 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); if (isRetry) { logger.info(`Decrypted event on retry (id=${this.getId()})`); } } } catch (e) { if (e.name !== "DecryptionError") { // not a decryption error: log the whole exception as an error // (and don't bother with a retry) const re = isRetry ? 're' : ''; logger.error( `Error ${re}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 || []; this._untrusted = decryptionResult.untrusted || false; }, /** * 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.isState() && 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; }, /** * Whether the decryption key was obtained from an untrusted source. If so, * we cannot verify the authenticity of the message. * * @return {boolean} */ isKeySourceUntrusted: function() { return this._untrusted; }, 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 (decrypted, if necessary) redaction event JSON * if event was redacted * * @returns {object} The redaction event JSON, or an empty object */ getRedactionEvent: function() { if (!this.isRedacted()) return null; if (this._clearEvent.unsigned) { return this._clearEvent.unsigned.redacted_because; } else if (this.event.unsigned.redacted_because) { return this.event.unsigned.redacted_because; } else { return {}; } }, /** * Get the push actions, if known, for this event * * @return {?Object} push actions */ getPushActions: function() { return this._pushActions; }, /** * 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. * * @fires module:models/event.MatrixEvent#"Event.replaced" * * @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, }; }, setVerificationRequest: function(request) { this.verificationRequest = request; }, setTxnId(txnId) { this._txnId = txnId; }, getTxnId() { return this._txnId; }, }); /* _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. */ matrix-js-sdk-9.11.0/src/models/group.js000066400000000000000000000055541403504662500200610ustar00rootroot00000000000000/* Copyright 2017 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module models/group */ import * as utils from "../utils"; import {EventEmitter} from "events"; /** * 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 */ export 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; }; /** * 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; * }); */ matrix-js-sdk-9.11.0/src/models/relations.js000066400000000000000000000263741403504662500207300ustar00rootroot00000000000000/* 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 '../models/event'; import {logger} from '../logger'; /** * 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 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 = {}; this._annotationsBySender = {}; 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) { logger.error("Event must have relation info"); return; } const relationType = relation.rel_type; const eventType = event.getType(); if (this.relationType !== relationType || this.eventType !== eventType) { logger.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) { logger.error("Event must have relation info"); return; } const relationType = relation.rel_type; const eventType = event.getType(); if (this.relationType !== relationType || this.eventType !== eventType) { logger.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[key]; if (!eventsForKey) { eventsForKey = this._annotationsByKey[key] = new Set(); this._sortedAnnotationsByKey.push([key, eventsForKey]); } // Add the new event to the set for this key eventsForKey.add(event); // Re-sort the [key, events] pairs in descending order of event count this._sortedAnnotationsByKey.sort((a, b) => { const aEvents = a[1]; const bEvents = b[1]; return bEvents.size - aEvents.size; }); const sender = event.getSender(); let eventsFromSender = this._annotationsBySender[sender]; if (!eventsFromSender) { eventsFromSender = this._annotationsBySender[sender] = new Set(); } // 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[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[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", redactedEvent); } /** * 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 {Object} * 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); } } } } matrix-js-sdk-9.11.0/src/models/room-member.js000066400000000000000000000322331403504662500211400ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module models/room-member */ import {EventEmitter} from "events"; import {getHttpUriForMxc} from "../content-repo"; import * as utils from "../utils"; /** * Construct a new room member. * * @constructor * @alias module:models/room-member * * @param {string} roomId The room ID of the member. * @param {string} userId The user ID of the member. * @prop {string} roomId The room ID for this member. * @prop {string} userId The user ID of this member. * @prop {boolean} typing True if the room member is currently typing. * @prop {string} name The human-readable name for this room member. This will be * disambiguated with a suffix of " (@user_id:matrix.org)" if another member shares the * same displayname. * @prop {string} rawDisplayName The ambiguous displayname of this room member. * @prop {Number} powerLevel The power level for this room member. * @prop {Number} powerLevelNorm The normalised power level (0-100) for this * room member. * @prop {User} user The User object for this room member, if one exists. * @prop {string} membership The membership state for this room member e.g. 'join'. * @prop {Object} events The events describing this RoomMember. * @prop {MatrixEvent} events.member The m.room.member event for this RoomMember. */ export function RoomMember(roomId, userId) { this.roomId = roomId; this.userId = userId; this.typing = false; this.name = userId; this.rawDisplayName = userId; this.powerLevel = 0; this.powerLevelNorm = 0; this.user = null; this.membership = null; this.events = { member: null, }; this._isOutOfBand = false; this._updateModifiedTime(); } 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 = getHttpUriForMxc( baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks, ); if (httpUrl) { return httpUrl; } return null; }; /** * get the mxc avatar url, either from a state event, or from a lazily loaded member * @return {string} the mxc avatar url */ RoomMember.prototype.getMxcAvatarUrl = function() { if (this.events.member) { return this.events.member.getDirectionalContent().avatar_url; } else if (this.user) { return this.user.avatarUrl; } return null; }; const MXID_PATTERN = /@.+:.+/; const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; function 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 let disambiguate = MXID_PATTERN.test(displayName); if (!disambiguate) { // Also show mxid if the display name contains any LTR/RTL characters as these // make it very difficult for us to find similar *looking* display names // E.g "Mark" could be cloned by writing "kraM" but in RTL. disambiguate = LTR_RTL_PATTERN.test(displayName); } if (!disambiguate) { // Also show mxid if there are other people with the same or similar // displayname, after hidden character removal. const userIds = roomState.getUserIdsWithDisplayName(displayName); disambiguate = userIds.some((u) => u !== selfUserId); } if (disambiguate) { return displayName + " (" + selfUserId + ")"; } return displayName; } /** * Fires whenever any room member's name changes. * @event module:client~MatrixClient#"RoomMember.name" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {RoomMember} member The member whose RoomMember.name changed. * @param {string?} oldName The previous name. Null if the member didn't have a * name previously. * @example * matrixClient.on("RoomMember.name", function(event, member){ * var newName = member.name; * }); */ /** * Fires whenever any room member's membership state changes. * @event module:client~MatrixClient#"RoomMember.membership" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {RoomMember} member The member whose RoomMember.membership changed. * @param {string?} oldMembership The previous membership state. Null if it's a * new member. * @example * matrixClient.on("RoomMember.membership", function(event, member, oldMembership){ * var newState = member.membership; * }); */ /** * Fires whenever any room member's typing state changes. * @event module:client~MatrixClient#"RoomMember.typing" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {RoomMember} member The member whose RoomMember.typing changed. * @example * matrixClient.on("RoomMember.typing", function(event, member){ * var isTyping = member.typing; * }); */ /** * Fires whenever any room member's power level changes. * @event module:client~MatrixClient#"RoomMember.powerLevel" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {RoomMember} member The member whose RoomMember.powerLevel changed. * @example * matrixClient.on("RoomMember.powerLevel", function(event, member){ * var newPowerLevel = member.powerLevel; * var newNormPowerLevel = member.powerLevelNorm; * }); */ matrix-js-sdk-9.11.0/src/models/room-state.js000066400000000000000000000743701403504662500210210ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module models/room-state */ import {EventEmitter} from "events"; import {RoomMember} from "./room-member"; import {logger} from '../logger'; import * as utils from "../utils"; import {EventType} from "../@types/event"; // possible statuses for out-of-band member loading const OOB_STATUS_NOTSTARTED = 1; const OOB_STATUS_INPROGRESS = 2; const OOB_STATUS_FINISHED = 3; /** * Construct room state. * * Room State represents the state of the room at a given point. * It can be mutated by adding state events to it. * There are two types of room member associated with a state event: * normal member objects (accessed via getMember/getMembers) which mutate * with the state to represent the current state of that room/user, eg. * the object returned by getMember('@bob:example.com') will mutate to * get a different display name if Bob later changes his display name * in the room. * There are also 'sentinel' members (accessed via getSentinelMember). * These also represent the state of room members at the point in time * represented by the RoomState object, but unlike objects from getMember, * sentinel objects will always represent the room state as at the time * getSentinelMember was called, so if Bob subsequently changes his display * name, a room member object previously acquired with getSentinelMember * will still have his old display name. Calling getSentinelMember again * after the display name change will return a new RoomMember object * with Bob's new display name. * * @constructor * @param {?string} roomId Optional. The ID of the room which has this state. * If none is specified it just tracks paginationTokens, useful for notifTimelineSet * @param {?object} oobMemberFlags Optional. The state of loading out of bound members. * As the timeline might get reset while they are loading, this state needs to be inherited * and shared when the room state is cloned for the new timeline. * This should only be passed from clone. * @prop {Object.} members The room member dictionary, keyed * on the user's ID. * @prop {Object.>} events The state * events dictionary, keyed on the event type and then the state_key value. * @prop {string} paginationToken The pagination token for this state. */ export function RoomState(roomId, oobMemberFlags = undefined) { this.roomId = roomId; this.members = { // userId: RoomMember }; this.events = new Map(); // Map> this.paginationToken = null; this._sentinels = { // userId: RoomMember }; this._updateModifiedTime(); // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) this._displayNameToUserIds = {}; this._userIdsToDisplayNames = {}; this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite this._joinedMemberCount = null; // cache of the number of joined members // joined members count from summary api // once set, we know the server supports the summary api // and we should only trust that // we could also only trust that before OOB members // are loaded but doesn't seem worth the hassle atm this._summaryJoinedMemberCount = null; // same for invited member count this._invitedMemberCount = null; this._summaryInvitedMemberCount = null; if (!oobMemberFlags) { oobMemberFlags = { status: OOB_STATUS_NOTSTARTED, }; } this._oobMemberFlags = oobMemberFlags; } utils.inherits(RoomState, EventEmitter); /** * Returns the number of joined members in this room * This method caches the result. * @return {integer} The number of members in this room whose membership is 'join' */ RoomState.prototype.getJoinedMemberCount = function() { if (this._summaryJoinedMemberCount !== null) { return this._summaryJoinedMemberCount; } if (this._joinedMemberCount === null) { this._joinedMemberCount = this.getMembers().reduce((count, m) => { return m.membership === 'join' ? count + 1 : count; }, 0); } return this._joinedMemberCount; }; /** * Set the joined member count explicitly (like from summary part of the sync response) * @param {number} count the amount of joined members */ RoomState.prototype.setJoinedMemberCount = function(count) { this._summaryJoinedMemberCount = count; }; /** * Returns the number of invited members in this room * @return {integer} The number of members in this room whose membership is 'invite' */ RoomState.prototype.getInvitedMemberCount = function() { if (this._summaryInvitedMemberCount !== null) { return this._summaryInvitedMemberCount; } if (this._invitedMemberCount === null) { this._invitedMemberCount = this.getMembers().reduce((count, m) => { return m.membership === 'invite' ? count + 1 : count; }, 0); } return this._invitedMemberCount; }; /** * Set the amount of invited members in this room * @param {number} count the amount of invited members */ RoomState.prototype.setInvitedMemberCount = function(count) { this._summaryInvitedMemberCount = count; }; /** * Get all RoomMembers in this room. * @return {Array} A list of RoomMembers. */ RoomState.prototype.getMembers = function() { return 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 Array.from(this.events.get(eventType).values()); } const event = this.events.get(eventType).get(stateKey); return event ? event : null; }; /** * Creates a copy of this room state so that mutations to either won't affect the other. * @return {RoomState} the copy of the room state */ RoomState.prototype.clone = function() { const copy = new RoomState(this.roomId, this._oobMemberFlags); // Ugly hack: because setStateEvents will mark // members as susperseding future out of bound members // if loading is in progress (through _oobMemberFlags) // since these are not new members, we're merely copying them // set the status to not started // after copying, we set back the status const status = this._oobMemberFlags.status; this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; Array.from(this.events.values()).forEach((eventsByStateKey) => { copy.setStateEvents(Array.from(eventsByStateKey.values())); }); // Ugly hack: see above this._oobMemberFlags.status = status; if (this._summaryInvitedMemberCount !== null) { copy.setInvitedMemberCount(this.getInvitedMemberCount()); } if (this._summaryJoinedMemberCount !== null) { copy.setJoinedMemberCount(this.getJoinedMemberCount()); } // copy out of band flags if needed if (this._oobMemberFlags.status == OOB_STATUS_FINISHED) { // copy markOutOfBand flags this.getMembers().forEach((member) => { if (member.isOutOfBand()) { const copyMember = copy.getMember(member.userId); copyMember.markOutOfBand(); } }); } return copy; }; /** * Add previously unknown state events. * When lazy loading members while back-paginating, * the relevant room state for the timeline chunk at the end * of the chunk can be set with this method. * @param {MatrixEvent[]} events state events to prepend */ RoomState.prototype.setUnknownStateEvents = function(events) { const unknownStateEvents = events.filter((event) => { return !this.events.has(event.getType()) || !this.events.get(event.getType()).has(event.getStateKey()); }); this.setStateEvents(unknownStateEvents); }; /** * Add an array of one or more state MatrixEvents, overwriting * any existing state with the same {type, stateKey} tuple. Will fire * "RoomState.events" for every event added. May fire "RoomState.members" * if there are m.room.member events. * @param {MatrixEvent[]} stateEvents a list of state events for this room. * @fires module:client~MatrixClient#event:"RoomState.members" * @fires module:client~MatrixClient#event:"RoomState.newMember" * @fires module:client~MatrixClient#event:"RoomState.events" */ RoomState.prototype.setStateEvents = function(stateEvents) { const self = this; this._updateModifiedTime(); // update the core event dict utils.forEach(stateEvents, function(event) { if (event.getRoomId() !== self.roomId) { return; } if (!event.isState()) { return; } const lastStateEvent = self._getStateEventMatching(event); self._setStateEvent(event); if (event.getType() === "m.room.member") { _updateDisplayNameCache( self, event.getStateKey(), event.getContent().displayname, ); _updateThirdPartyTokenCache(self, event); } self.emit("RoomState.events", event, self, lastStateEvent); }); // update higher level data structures. This needs to be done AFTER the // core event dict as these structures may depend on other state events in // the given array (e.g. disambiguating display names in one go to do both // clashing names rather than progressively which only catches 1 of them). 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.has(event.getType())) { this.events.set(event.getType(), new Map()); } this.events.get(event.getType()).set(event.getStateKey(), event); }; RoomState.prototype._getStateEventMatching = function(event) { if (!this.events.has(event.getType())) return null; return this.events.get(event.getType()).get(event.getStateKey()); }; RoomState.prototype._updateMember = function(member) { // this member may have a power level already, so set it. const pwrLvlEvent = this.getStateEvents("m.room.power_levels", ""); if (pwrLvlEvent) { member.setPowerLevelEvent(pwrLvlEvent); } // blow away the sentinel which is now outdated delete this._sentinels[member.userId]; this.members[member.userId] = member; this._joinedMemberCount = null; this._invitedMemberCount = null; }; /** * Get the out-of-band members loading state, whether loading is needed or not. * Note that loading might be in progress and hence isn't needed. * @return {bool} whether or not the members of this room need to be loaded */ RoomState.prototype.needsOutOfBandMembers = function() { return this._oobMemberFlags.status === OOB_STATUS_NOTSTARTED; }; /** * Mark this room state as waiting for out-of-band members, * ensuring it doesn't ask for them to be requested again * through needsOutOfBandMembers */ RoomState.prototype.markOutOfBandMembersStarted = function() { if (this._oobMemberFlags.status !== OOB_STATUS_NOTSTARTED) { return; } this._oobMemberFlags.status = OOB_STATUS_INPROGRESS; }; /** * Mark this room state as having failed to fetch out-of-band members */ RoomState.prototype.markOutOfBandMembersFailed = function() { if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) { return; } this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; }; /** * Clears the loaded out-of-band members */ RoomState.prototype.clearOutOfBandMembers = function() { let count = 0; Object.keys(this.members).forEach((userId) => { const member = this.members[userId]; if (member.isOutOfBand()) { ++count; delete this.members[userId]; } }); logger.log(`LL: RoomState removed ${count} members...`); this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; }; /** * Sets the loaded out-of-band members. * @param {MatrixEvent[]} stateEvents array of membership state events */ RoomState.prototype.setOutOfBandMembers = function(stateEvents) { logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`); if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) { return; } logger.log(`LL: RoomState put in OOB_STATUS_FINISHED state ...`); this._oobMemberFlags.status = OOB_STATUS_FINISHED; stateEvents.forEach((e) => this._setOutOfBandMember(e)); }; /** * Sets a single out of band member, used by both setOutOfBandMembers and clone * @param {MatrixEvent} stateEvent membership state event */ RoomState.prototype._setOutOfBandMember = function(stateEvent) { if (stateEvent.getType() !== 'm.room.member') { return; } const userId = stateEvent.getStateKey(); const existingMember = this.getMember(userId); // never replace members received as part of the sync if (existingMember && !existingMember.isOutOfBand()) { return; } const member = this._getOrCreateMember(userId, stateEvent); member.setMembershipEvent(stateEvent, this); // needed to know which members need to be stored seperately // as they are not part of the sync accumulator // this is cleared by setMembershipEvent so when it's updated through /sync member.markOutOfBand(); _updateDisplayNameCache(this, member.userId, member.name); this._setStateEvent(stateEvent); this._updateMember(member); this.emit("RoomState.members", stateEvent, this, member); }; /** * Set the current typing event for this room. * @param {MatrixEvent} event The typing event */ RoomState.prototype.setTypingEvent = function(event) { 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[utils.removeHiddenChars(displayName)] || []; }; /** * Returns true if userId is in room, event is not redacted and either sender of * mxEvent or has power level sufficient to redact events other than their own. * @param {MatrixEvent} mxEvent The event to test permission for * @param {string} userId The user ID of the user to test permission for * @return {boolean} true if the given used ID can redact given event */ RoomState.prototype.maySendRedactionForEvent = function(mxEvent, userId) { const member = this.getMember(userId); if (!member || member.membership === 'leave') return false; if (mxEvent.status || mxEvent.isRedacted()) return false; // The user may have been the sender, but they can't redact their own message // if redactions are blocked. const canRedact = this.maySendEvent("m.room.redaction", userId); if (mxEvent.getSender() === userId) return canRedact; return this._hasSufficientPowerLevelFor('redact', member.powerLevel); }; /** * Returns true if the given power level is sufficient for action * @param {string} action The type of power level to check * @param {number} powerLevel The power level of the member * @return {boolean} true if the given power level is sufficient */ RoomState.prototype._hasSufficientPowerLevelFor = function(action, powerLevel) { const powerLevelsEvent = this.getStateEvents('m.room.power_levels', ''); let powerLevels = {}; if (powerLevelsEvent) { powerLevels = powerLevelsEvent.getContent(); } let requiredLevel = 50; if (utils.isNumber(powerLevels[action])) { requiredLevel = powerLevels[action]; } return powerLevel >= requiredLevel; }; /** * Short-form for maySendEvent('m.room.message', userId) * @param {string} userId The user ID of the user to test permission for * @return {boolean} true if the given user ID should be permitted to send * message events into the given room. */ RoomState.prototype.maySendMessage = function(userId) { return this._maySendEventOfType('m.room.message', userId, false); }; /** * Returns true if the given user ID has permission to send a normal * event of type `eventType` into this room. * @param {string} eventType The type of event to test * @param {string} userId The user ID of the user to test permission for * @return {boolean} true if the given user ID should be permitted to send * the given type of event into this room, * according to the room's state. */ RoomState.prototype.maySendEvent = function(eventType, userId) { return this._maySendEventOfType(eventType, userId, false); }; /** * Returns true if the given MatrixClient has permission to send a state * event of type `stateEventType` into this room. * @param {string} stateEventType The type of state events to test * @param {MatrixClient} cli The client to test permission for * @return {boolean} true if the given client should be permitted to send * the given type of state event into this room, * according to the room's state. */ RoomState.prototype.mayClientSendStateEvent = function(stateEventType, cli) { if (cli.isGuest()) { return false; } return this.maySendStateEvent(stateEventType, cli.credentials.userId); }; /** * Returns true if the given user ID has permission to send a state * event of type `stateEventType` into this room. * @param {string} stateEventType The type of state events to test * @param {string} userId The user ID of the user to test permission for * @return {boolean} true if the given user ID should be permitted to send * the given type of state event into this room, * according to the room's state. */ RoomState.prototype.maySendStateEvent = function(stateEventType, userId) { return this._maySendEventOfType(stateEventType, userId, true); }; /** * Returns true if the given user ID has permission to send a normal or state * event of type `eventType` into this room. * @param {string} eventType The type of event to test * @param {string} userId The user ID of the user to test permission for * @param {boolean} state If true, tests if the user may send a state event of this type. Otherwise tests whether they may send a regular event. * @return {boolean} true if the given user ID should be permitted to send * the given type of event into this room, * according to the room's state. */ RoomState.prototype._maySendEventOfType = function(eventType, userId, state) { const power_levels_event = this.getStateEvents('m.room.power_levels', ''); let power_levels; let events_levels = {}; let state_default = 0; let events_default = 0; let powerLevel = 0; if (power_levels_event) { power_levels = power_levels_event.getContent(); events_levels = power_levels.events || {}; if (Number.isFinite(power_levels.state_default)) { state_default = power_levels.state_default; } else { state_default = 50; } const userPowerLevel = power_levels.users && power_levels.users[userId]; if (Number.isFinite(userPowerLevel)) { powerLevel = userPowerLevel; } else if (Number.isFinite(power_levels.users_default)) { powerLevel = power_levels.users_default; } if (Number.isFinite(power_levels.events_default)) { events_default = power_levels.events_default; } } let required_level = state ? state_default : events_default; if (Number.isFinite(events_levels[eventType])) { required_level = events_levels[eventType]; } return powerLevel >= required_level; }; /** * Returns true if the given user ID has permission to trigger notification * of type `notifLevelKey` * @param {string} notifLevelKey The level of notification to test (eg. 'room') * @param {string} userId The user ID of the user to test permission for * @return {boolean} true if the given user ID has permission to trigger a * notification of this type. */ RoomState.prototype.mayTriggerNotifOfType = function(notifLevelKey, userId) { const member = this.getMember(userId); if (!member) { return false; } const powerLevelsEvent = this.getStateEvents('m.room.power_levels', ''); let notifLevel = 50; if ( powerLevelsEvent && powerLevelsEvent.getContent() && powerLevelsEvent.getContent().notifications && utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey]) ) { notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey]; } return member.powerLevel >= notifLevel; }; /** * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. * @returns {string} the join_rule applied to this room */ RoomState.prototype.getJoinRule = function() { const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, ""); const joinRuleContent = joinRuleEvent ? joinRuleEvent.getContent() : {}; return joinRuleContent["join_rule"] || "invite"; }; function _updateThirdPartyTokenCache(roomState, memberEvent) { if (!memberEvent.getContent().third_party_invite) { return; } const token = (memberEvent.getContent().third_party_invite.signed || {}).token; if (!token) { return; } const threePidInvite = roomState.getStateEvents( "m.room.third_party_invite", token, ); if (!threePidInvite) { return; } roomState._tokenToInvite[token] = memberEvent; } function _updateDisplayNameCache(roomState, userId, displayName) { const oldName = roomState._userIdsToDisplayNames[userId]; delete roomState._userIdsToDisplayNames[userId]; if (oldName) { // Remove the old name from the cache. // We clobber the user_id > name lookup but the name -> [user_id] lookup // means we need to remove that user ID from that array rather than nuking // the lot. const strippedOldName = utils.removeHiddenChars(oldName); const existingUserIds = roomState._displayNameToUserIds[strippedOldName]; if (existingUserIds) { // remove this user ID from this array const filteredUserIDs = existingUserIds.filter((id) => id !== userId); roomState._displayNameToUserIds[strippedOldName] = filteredUserIDs; } } roomState._userIdsToDisplayNames[userId] = displayName; const strippedDisplayname = displayName && utils.removeHiddenChars(displayName); // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js if (strippedDisplayname) { if (!roomState._displayNameToUserIds[strippedDisplayname]) { roomState._displayNameToUserIds[strippedDisplayname] = []; } roomState._displayNameToUserIds[strippedDisplayname].push(userId); } } /** * Fires whenever the event dictionary in room state is updated. * @event module:client~MatrixClient#"RoomState.events" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {RoomState} state The room state whose RoomState.events dictionary * was updated. * @param {MatrixEvent} prevEvent The event being replaced by the new state, if * known. Note that this can differ from `getPrevContent()` on the new state event * as this is the store's view of the last state, not the previous state provided * by the server. * @example * matrixClient.on("RoomState.events", function(event, state, prevEvent){ * var newStateEvent = event; * }); */ /** * Fires whenever a member in the members dictionary is updated in any way. * @event module:client~MatrixClient#"RoomState.members" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {RoomState} state The room state whose RoomState.members dictionary * was updated. * @param {RoomMember} member The room member that was updated. * @example * matrixClient.on("RoomState.members", function(event, state, member){ * var newMembershipState = member.membership; * }); */ /** * Fires whenever a member is added to the members dictionary. The RoomMember * will not be fully populated yet (e.g. no membership state) but will already * be available in the members dictionary. * @event module:client~MatrixClient#"RoomState.newMember" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {RoomState} state The room state whose RoomState.members dictionary * was updated with a new entry. * @param {RoomMember} member The room member that was added. * @example * matrixClient.on("RoomState.newMember", function(event, state, member){ * // add event listeners on 'member' * }); */ matrix-js-sdk-9.11.0/src/models/room-summary.js000066400000000000000000000026301403504662500213640ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module models/room-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. */ export function RoomSummary(roomId, info) { this.roomId = roomId; this.info = info; } matrix-js-sdk-9.11.0/src/models/room.js000066400000000000000000002257011403504662500176770ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2018, 2019 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module models/room */ import {EventEmitter} from "events"; import {EventTimelineSet} from "./event-timeline-set"; import {EventTimeline} from "./event-timeline"; import {getHttpUriForMxc} from "../content-repo"; import * as utils from "../utils"; import {EventStatus, MatrixEvent} from "./event"; import {RoomMember} from "./room-member"; import {RoomSummary} from "./room-summary"; import {logger} from '../logger'; import {ReEmitter} from '../ReEmitter'; import {EventType, RoomCreateTypeField, RoomType} from "../@types/event"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be // the same as the common default room version whereas SAFE_ROOM_VERSIONS are the // room versions which are considered okay for people to run without being asked // to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers // return an m.room_versions capability. const KNOWN_SAFE_ROOM_VERSION = '6'; const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6']; function synthesizeReceipt(userId, event, receiptType) { // console.log("synthesizing receipt for "+event.getId()); // This is really ugly because JS has no way to express an object literal // where the name of a key comes from an expression const fakeReceipt = { content: {}, type: "m.receipt", room_id: event.getRoomId(), }; fakeReceipt.content[event.getId()] = {}; fakeReceipt.content[event.getId()][receiptType] = {}; fakeReceipt.content[event.getId()][receiptType][userId] = { ts: event.getTs(), }; return new MatrixEvent(fakeReceipt); } /** * Construct a new Room. * *

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

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

    In order that we can find events from their ids later, we also maintain a * map from event_id to timeline and index. * * @constructor * @alias module:models/room * @param {string} roomId Required. The ID of this room. * @param {MatrixClient} client Required. The client, used to lazy load members. * @param {string} myUserId Required. The ID of the syncing user. * @param {Object=} opts Configuration options * @param {*} opts.storageToken Optional. The token which a data store can use * to remember the state of the room. What this means is dependent on the store * implementation. * * @param {String=} opts.pendingEventOrdering Controls where pending messages * appear in a room's timeline. If "chronological", messages will appear * in the timeline when the call to sendEvent was made. If * "detached", pending messages will appear in a separate list, * accessbile via {@link module:models/room#getPendingEvents}. Default: * "chronological". * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved * timeline support. * @param {boolean} [opts.unstableClientRelationAggregation = false] * Optional. Set to true to enable client-side aggregation of event relations * via `EventTimelineSet#getRelationsForEvent`. * This feature is currently unstable and the API may change without notice. * * @prop {string} roomId The ID of this room. * @prop {string} name The human-readable display name for this room. * @prop {Array} timeline The live event timeline for this room, * with the oldest event at index 0. Present for backwards compatibility - * prefer getLiveTimeline().getEvents(). * @prop {object} tags Dict of room tags; the keys are the tag name and the values * are any metadata associated with the tag - e.g. { "fav" : { order: 1 } } * @prop {object} accountData Dict of per-room account_data events; the keys are the * event type and the values are the events. * @prop {RoomState} oldState The state of the room at the time of the oldest * event in the live timeline. Present for backwards compatibility - * prefer getLiveTimeline().getState(EventTimeline.BACKWARDS). * @prop {RoomState} currentState The state of the room at the time of the * newest event in the timeline. Present for backwards compatibility - * prefer getLiveTimeline().getState(EventTimeline.FORWARDS). * @prop {RoomSummary} summary The room summary. * @prop {*} storageToken A token which a data store can use to remember * the state of the room. */ export function Room(roomId, client, myUserId, opts) { opts = opts || {}; opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; // In some cases, we add listeners for every displayed Matrix event, so it's // common to have quite a few more than the default limit. this.setMaxListeners(100); this.reEmitter = new ReEmitter(this); if (["chronological", "detached"].indexOf(opts.pendingEventOrdering) === -1) { throw new Error( "opts.pendingEventOrdering MUST be either 'chronological' or " + "'detached'. Got: '" + opts.pendingEventOrdering + "'", ); } this.myUserId = myUserId; this.roomId = roomId; this.name = roomId; this.tags = { // $tagName: { $metadata: $value }, // $tagName: { $metadata: $value }, }; this.accountData = { // $eventType: $event }; this.summary = null; this.storageToken = opts.storageToken; this._opts = opts; this._txnToEvent = {}; // Pending in-flight requests { string: MatrixEvent } // receipts should clobber based on receipt_type and user_id pairs hence // the form of this structure. This is sub-optimal for the exposed APIs // which pass in an event ID and get back some receipts, so we also store // a pre-cached list for this purpose. this._receipts = { // receipt_type: { // user_id: { // eventId: , // data: // } // } }; this._receiptCacheByEventId = { // $event_id: [{ // type: $type, // userId: $user_id, // data: // }] }; // only receipts that came from the server, not synthesized ones this._realReceipts = {}; this._notificationCounts = {}; // all our per-room timeline sets. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. this._timelineSets = [new EventTimelineSet(this, opts)]; this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), ["Room.timeline", "Room.timelineReset"]); this._fixUpLegacyTimelineFields(); // any filtered timeline sets we're maintaining for this room this._filteredTimelineSets = { // filter_id: timelineSet }; if (this._opts.pendingEventOrdering == "detached") { this._pendingEventList = []; } // 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.roomId + " 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. * If pending event ordering is not "detached" then this returns false. * * @param {string} eventId The event ID to check for. * @return {boolean} */ Room.prototype.hasPendingEvent = function(eventId) { if (this._opts.pendingEventOrdering !== "detached") { return false; } return this._pendingEventList.some(event => event.getId() === eventId); }; /** * Get a specific event from the pending event list, if configured, null otherwise. * * @param {string} eventId The event ID to check for. * @return {MatrixEvent} */ Room.prototype.getPendingEvent = function(eventId) { if (this._opts.pendingEventOrdering !== "detached") { return null; } return this._pendingEventList.find(event => event.getId() === eventId); }; /** * Get the live unfiltered timeline for this room. * * @return {module:models/event-timeline~EventTimeline} live timeline */ Room.prototype.getLiveTimeline = function() { return this.getUnfilteredTimelineSet().getLiveTimeline(); }; /** * Get the timestamp of the last message in the room * * @return {number} the timestamp of the last message in the room */ Room.prototype.getLastActiveTimestamp = function() { const timeline = this.getLiveTimeline(); const events = timeline.getEvents(); if (events.length) { const lastEvent = events[events.length - 1]; return lastEvent.getTs(); } else { return Number.MIN_SAFE_INTEGER; } }; /** * @param {string} myUserId the user id for the logged in member * @return {string} the membership type (join | leave | invite) for the logged in user */ Room.prototype.getMyMembership = function() { return this._selfMembership; }; /** * If this room is a DM we're invited to, * try to find out who invited us * @return {string} user id of the inviter */ Room.prototype.getDMInviter = function() { if (this.myUserId) { const me = this.getMember(this.myUserId); if (me) { return me.getDMInviter(); } } if (this._selfMembership === "invite") { // fall back to summary information const memberCount = this.getInvitedAndJoinedMemberCount(); if (memberCount == 2 && this._summaryHeroes.length) { return this._summaryHeroes[0]; } } }; /** * Assuming this room is a DM room, tries to guess with which user. * @return {string} user id of the other member (could be syncing user) */ Room.prototype.guessDMUserId = function() { const me = this.getMember(this.myUserId); if (me) { const inviterId = me.getDMInviter(); if (inviterId) { return inviterId; } } // remember, we're assuming this room is a DM, // so returning the first member we find should be fine const hasHeroes = Array.isArray(this._summaryHeroes) && this._summaryHeroes.length; if (hasHeroes) { return this._summaryHeroes[0]; } const members = this.currentState.getMembers(); const anyMember = members.find((m) => m.userId !== this.myUserId); if (anyMember) { return anyMember.userId; } // it really seems like I'm the only user in the room // so I probably created a room with just me in it // and marked it as a DM. Ok then return this.myUserId; }; Room.prototype.getAvatarFallbackMember = function() { const memberCount = this.getInvitedAndJoinedMemberCount(); if (memberCount > 2) { return; } const hasHeroes = Array.isArray(this._summaryHeroes) && this._summaryHeroes.length; if (hasHeroes) { const availableMember = this._summaryHeroes.map((userId) => { return this.getMember(userId); }).find((member) => !!member); if (availableMember) { return availableMember; } } const members = this.currentState.getMembers(); // could be different than memberCount // as this includes left members if (members.length <= 2) { const availableMember = members.find((m) => { return m.userId !== this.myUserId; }); if (availableMember) { return availableMember; } } // if all else fails, try falling back to a user, // and create a one-off member for it if (hasHeroes) { const availableUser = this._summaryHeroes.map((userId) => { return this._client.getUser(userId); }).find((user) => !!user); if (availableUser) { const member = new RoomMember( this.roomId, availableUser.userId); member.user = availableUser; return member; } } }; /** * Sets the membership this room was received as during sync * @param {string} membership join | leave | invite */ Room.prototype.updateMyMembership = function(membership) { const prevMembership = this._selfMembership; this._selfMembership = membership; if (prevMembership !== membership) { if (membership === "leave") { this._cleanupAfterLeaving(); } this.emit("Room.myMembership", this, membership, prevMembership); } }; Room.prototype._loadMembersFromServer = async function() { const lastSyncToken = this._client.store.getSyncToken(); const queryString = utils.encodeParams({ not_membership: "leave", at: lastSyncToken, }); const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, {$roomId: this.roomId}); const http = this._client._http; const response = await http.authedRequest(undefined, "GET", path); return response.chunk; }; Room.prototype._loadMembers = async function() { // were the members loaded from the server? let fromServer = false; let rawMembersEvents = await this._client.store.getOutOfBandMembers(this.roomId); if (rawMembersEvents === null) { fromServer = true; rawMembersEvents = await this._loadMembersFromServer(); logger.log(`LL: got ${rawMembersEvents.length} ` + `members from server for room ${this.roomId}`); } const memberEvents = rawMembersEvents.map(this._client.getEventMapper()); return {memberEvents, fromServer}; }; /** * Preloads the member list in case lazy loading * of memberships is in use. Can be called multiple times, * it will only preload once. * @return {Promise} when preloading is done and * accessing the members on the room will take * all members in the room into account */ Room.prototype.loadMembersIfNeeded = function() { if (this._membersPromise) { return this._membersPromise; } // mark the state so that incoming messages while // the request is in flight get marked as superseding // the OOB members this.currentState.markOutOfBandMembersStarted(); const inMemoryUpdate = this._loadMembers().then((result) => { this.currentState.setOutOfBandMembers(result.memberEvents); // now the members are loaded, start to track the e2e devices if needed if (this._client.isCryptoEnabled() && this._client.isRoomEncrypted(this.roomId)) { this._client._crypto.trackRoomDevices(this.roomId); } return result.fromServer; }).catch((err) => { // allow retries on fail this._membersPromise = null; this.currentState.markOutOfBandMembersFailed(); throw err; }); // update members in storage, but don't wait for it inMemoryUpdate.then((fromServer) => { if (fromServer) { const oobMembers = this.currentState.getMembers() .filter((m) => m.isOutOfBand()) .map((m) => m.events.member.event); logger.log(`LL: telling store to write ${oobMembers.length}` + ` members for room ${this.roomId}`); const store = this._client.store; return store.setOutOfBandMembers(this.roomId, oobMembers) // swallow any IDB error as we don't want to fail // because of this .catch((err) => { logger.log("LL: storing OOB room members failed, oh well", err); }); } }).catch((err) => { // as this is not awaited anywhere, // at least show the error in the console logger.error(err); }); this._membersPromise = inMemoryUpdate; return this._membersPromise; }; /** * Removes the lazily loaded members from storage if needed */ Room.prototype.clearLoadedMembersIfNeeded = async function() { if (this._opts.lazyLoadMembers && this._membersPromise) { await this.loadMembersIfNeeded(); await this._client.store.clearOutOfBandMembers(this.roomId); this.currentState.clearOutOfBandMembers(); this._membersPromise = null; } }; /** * called when sync receives this room in the leave section * to do cleanup after leaving a room. Possibly called multiple times. */ Room.prototype._cleanupAfterLeaving = function() { this.clearLoadedMembersIfNeeded().catch((err) => { logger.error(`error after clearing loaded members from ` + `room ${this.roomId} after leaving`); logger.log(err); }); }; /** * Reset the live timeline of all timelineSets, and start new ones. * *

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

    Will fire "Room.timeline" for each event added. * * @param {MatrixEvent[]} events A list of events to add. * * @param {boolean} toStartOfTimeline True to add these events to the start * (oldest) instead of the end (newest) of the timeline. If true, the oldest * event will be the last element of 'events'. * * @param {module:models/event-timeline~EventTimeline} timeline timeline to * add events to. * * @param {string=} paginationToken token for the next batch of events * * @fires module:client~MatrixClient#event:"Room.timeline" * */ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, timeline, paginationToken) { timeline.getTimelineSet().addEventsToTimeline( events, toStartOfTimeline, timeline, paginationToken, ); }; /** * Get a member from the current room state. * @param {string} userId The user ID of the member. * @return {RoomMember} The member or null. */ Room.prototype.getMember = function(userId) { return this.currentState.getMember(userId); }; /** * Get 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' * @param {boolean} fromCache whether the sync response came from cache * @fires module:client~MatrixClient#event:"Room.timeline" * @private */ Room.prototype._addLiveEvent = function(event, duplicateStrategy, fromCache) { if (event.isRedaction()) { const redactId = event.event.redacts; // if we know about this event, redact its contents now. const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); if (redactedEvent) { redactedEvent.makeRedacted(event); // If this is in the current state, replace it with the redacted version if (redactedEvent.getStateKey()) { const currentStateEvent = this.currentState.getStateEvents( redactedEvent.getType(), redactedEvent.getStateKey(), ); if (currentStateEvent.getId() === redactedEvent.getId()) { this.currentState.setStateEvents([redactedEvent]); } } this.emit("Room.redaction", event, this); // TODO: we stash user displaynames (among other things) in // RoomMember objects which are then attached to other events // (in the sender and target fields). We should get those // RoomMember objects to update themselves when the events that // they are based on are changed. } // FIXME: apply redactions to notification list // NB: We continue to add the redaction event to the timeline so // clients can say "so and so redacted an event" if they wish to. Also // this may be needed to trigger an update. } if (event.getUnsigned().transaction_id) { const existingEvent = this._txnToEvent[event.getUnsigned().transaction_id]; if (existingEvent) { // remote echo of an event we sent earlier this._handleRemoteEcho(event, existingEvent); return; } } // add to our timeline sets for (let i = 0; i < this._timelineSets.length; i++) { this._timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); } // synthesize and inject implicit read receipts // Done after adding the event because otherwise the app would get a read receipt // pointing to an event that wasn't yet in the timeline // Don't synthesize RR for m.room.redaction as this causes the RR to go missing. if (event.sender && event.getType() !== "m.room.redaction") { this.addReceipt(synthesizeReceipt( event.sender.userId, event, "m.read", ), true); // Any live events from a user could be taken as implicit // presence information: evidence that they are currently active. // ...except in a world where we use 'user.currentlyActive' to reduce // presence spam, this isn't very useful - we'll get a transition when // they are no longer currently active anyway. So don't bother to // reset the lastActiveAgo and lastPresenceTs from the RoomState's user. } }; /** * Add a pending outgoing event to this room. * *

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

    This is an internal method, intended for use by MatrixClient. * * @param {module:models/event.MatrixEvent} event The event to add. * * @param {string} txnId Transaction id for this outgoing event * * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" * * @throws if the event doesn't have status SENDING, or we aren't given a * unique transaction id. */ Room.prototype.addPendingEvent = function(event, txnId) { if (event.status !== EventStatus.SENDING) { 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 (timelineSet.getFilter().filterRoomTimeline([event]).length) { timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), false); } } else { timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), false); } } } this.emit("Room.localEchoUpdated", event, this, null, null); }; /** * Used to aggregate the local echo for a relation, and also * for re-applying a relation after it's redaction has been cancelled, * as the local echo for the redaction of the relation would have * un-aggregated the relation. Note that this is different from regular messages, * which are just kept detached for their local echo. * * Also note that live events are aggregated in the live EventTimelineSet. * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. */ Room.prototype._aggregateNonLiveRelation = function(event) { // TODO: We should consider whether this means it would be a better // design to lift the relations handling up to the room instead. for (let i = 0; i < this._timelineSets.length; i++) { const timelineSet = this._timelineSets[i]; if (timelineSet.getFilter()) { if (timelineSet.getFilter().filterRoomTimeline([event]).length) { timelineSet.aggregateRelations(event); } } else { timelineSet.aggregateRelations(event); } } }; /** * Deal with the echo of a message we sent. * *

    We move the event to the live timeline if it isn't there already, and * update it. * * @param {module:models/event.MatrixEvent} remoteEvent The event received from * /sync * @param {module:models/event.MatrixEvent} localEvent The local echo, which * should be either in the _pendingEventList or the timeline. * * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" * @private */ Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) { const oldEventId = localEvent.getId(); const newEventId = remoteEvent.getId(); const oldStatus = localEvent.status; logger.debug( `Got remote echo for event ${oldEventId} -> ${newEventId} ` + `old status ${oldStatus}`, ); // no longer pending delete this._txnToEvent[remoteEvent.getUnsigned().transaction_id]; // if it's in the pending list, remove it if (this._pendingEventList) { 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()} ` + `event ID ${event.getId()} -> ${newEventId}`, ); // if the message was sent, we expect an event id if (newStatus == EventStatus.SENT && !newEventId) { throw new Error("updatePendingEvent called with status=SENT, " + "but no new event id"); } // SENT races against /sync, so we have to special-case it. if (newStatus == EventStatus.SENT) { const timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId); if (timeline) { // we've already received the event via the event stream. // nothing more to do here. return; } } const oldStatus = event.status; const oldEventId = event.getId(); if (!oldStatus) { throw new Error("updatePendingEventStatus called on an event which is " + "not a local echo."); } const allowed = ALLOWED_TRANSITIONS[oldStatus]; if (!allowed || allowed.indexOf(newStatus) < 0) { throw new Error("Invalid EventStatus transition " + oldStatus + "->" + newStatus); } event.setStatus(newStatus); if (newStatus == EventStatus.SENT) { // update the event id event.replaceLocalEventId(newEventId); // if the event was already in the timeline (which will be the case if // opts.pendingEventOrdering==chronological), we need to update the // timeline map. for (let i = 0; i < this._timelineSets.length; i++) { this._timelineSets[i].replaceEventId(oldEventId, newEventId); } } else if (newStatus == EventStatus.CANCELLED) { // remove it from the pending event list, or the timeline. if (this._pendingEventList) { const idx = this._pendingEventList.findIndex(ev => ev.getId() === oldEventId); if (idx !== -1) { const [removedEvent] = this._pendingEventList.splice(idx, 1); if (removedEvent.isRedaction()) { this._revertRedactionLocalEcho(removedEvent); } } } this.removeEvent(oldEventId); } this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus); }; Room.prototype._revertRedactionLocalEcho = function(redactionEvent) { const redactId = redactionEvent.event.redacts; if (!redactId) { return; } const redactedEvent = this.getUnfilteredTimelineSet() .findEventById(redactId); if (redactedEvent) { redactedEvent.unmarkLocallyRedacted(); // re-render after undoing redaction this.emit("Room.redactionCancelled", redactionEvent, this); // reapply relation now redaction failed if (redactedEvent.isRelation()) { this._aggregateNonLiveRelation(redactedEvent); } } }; /** * Add some events to this room. This can include state events, message * events and typing notifications. These events are treated as "live" so * they will go to the end of the timeline. * * @param {MatrixEvent[]} events A list of events to add. * * @param {string} duplicateStrategy Optional. Applies to events in the * timeline only. If this is 'replace' then if a duplicate is encountered, the * event passed to this function will replace the existing event in the * timeline. If this is not specified, or is 'ignore', then the event passed to * this function will be ignored entirely, preserving the existing event in the * timeline. Events are identical based on their event ID only. * * @param {boolean} fromCache whether the sync response came from cache * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. */ Room.prototype.addLiveEvents = function(events, duplicateStrategy, fromCache) { let i; if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); } // sanity check that the live timeline is still live for (i = 0; i < this._timelineSets.length; i++) { const liveTimeline = this._timelineSets[i].getLiveTimeline(); if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { throw new Error( "live timeline " + i + " is no longer live - it has a pagination token " + "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")", ); } if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { throw new Error( "live timeline " + i + " is no longer live - " + "it has a neighbouring timeline", ); } } for (i = 0; i < events.length; i++) { // TODO: We should have a filter to say "only add state event // types X Y Z to the timeline". this._addLiveEvent(events[i], duplicateStrategy, fromCache); } }; /** * Adds/handles ephemeral events such as typing notifications and read receipts. * @param {MatrixEvent[]} events A list of events to process */ Room.prototype.addEphemeralEvents = function(events) { for (const event of events) { if (event.getType() === 'm.typing') { this.currentState.setTypingEvent(event); } else if (event.getType() === 'm.receipt') { this.addReceipt(event); } // else ignore - life is too short for us to care about these events } }; /** * Removes events from this room. * @param {String[]} eventIds A list of eventIds to remove. */ Room.prototype.removeEvents = function(eventIds) { for (let i = 0; i < eventIds.length; ++i) { this.removeEvent(eventIds[i]); } }; /** * Removes a single event from this room. * * @param {String} eventId The id of the event to remove * * @return {bool} true if the event was removed from any of the room's timeline sets */ Room.prototype.removeEvent = function(eventId) { let removedAny = false; for (let i = 0; i < this._timelineSets.length; i++) { const removed = this._timelineSets[i].removeEvent(eventId); if (removed) { if (removed.isRedaction()) { this._revertRedactionLocalEcho(removed); } removedAny = true; } } return removedAny; }; /** * Recalculate various aspects of the room, including the room name and * room summary. Call this any time the room's current state is modified. * May fire "Room.name" if the room name is updated. * @fires module:client~MatrixClient#event:"Room.name" */ Room.prototype.recalculate = function() { // set fake stripped state events if this is an invite room so logic remains // consistent elsewhere. const self = this; const membershipEvent = this.currentState.getStateEvents( "m.room.member", this.myUserId, ); if (membershipEvent && membershipEvent.getContent().membership === "invite") { const strippedStateEvents = membershipEvent.event.invite_room_state || []; 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); } const lastEvent = this.accountData[event.getType()]; this.accountData[event.getType()] = event; this.emit("Room.accountData", event, this, lastEvent); } }; /** * Access account_data event of given event type for this room * @param {string} type the type of account_data event to be accessed * @return {?MatrixEvent} the account_data event in question */ Room.prototype.getAccountData = function(type) { return this.accountData[type]; }; /** * Returns whether the syncing user has permission to send a message in the room * @return {boolean} true if the user should be permitted to send * message events into the room. */ Room.prototype.maySendMessage = function() { return this.getMyMembership() === 'join' && this.currentState.maySendEvent('m.room.message', this.myUserId); }; /** * Returns whether the given user has permissions to issue an invite for this room. * @param {string} userId the ID of the Matrix user to check permissions for * @returns {boolean} true if the user should be permitted to issue invites for this room. */ Room.prototype.canInvite = function(userId) { let canInvite = this.getMyMembership() === "join"; const powerLevelsEvent = this.currentState.getStateEvents(EventType.RoomPowerLevels, ""); const powerLevels = powerLevelsEvent && powerLevelsEvent.getContent(); const me = this.getMember(userId); if (powerLevels && me && powerLevels.invite > me.powerLevel) { canInvite = false; } return canInvite; }; /** * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. * @returns {string} the join_rule applied to this room */ Room.prototype.getJoinRule = function() { return this.currentState.getJoinRule(); }; /** * Returns the type of the room from the `m.room.create` event content or undefined if none is set * @returns {?string} the type of the room. Currently only RoomType.Space is known. */ Room.prototype.getType = function() { const createEvent = this.currentState.getStateEvents("m.room.create", ""); if (!createEvent) { logger.warn("Room " + this.roomId + " does not have an m.room.create event"); return undefined; } return createEvent.getContent()[RoomCreateTypeField]; }; /** * Returns whether the room is a space-room as defined by MSC1772. * @returns {boolean} true if the room's type is RoomType.Space */ Room.prototype.isSpaceRoom = function() { return this.getType() === RoomType.Space; }; /** * This is an internal method. Calculates the name of the room from the current * room state. * @param {Room} room The matrix room. * @param {string} userId The client's user ID. Used to filter room members * correctly. * @param {bool} ignoreRoomNameEvent Return the implicit room name that we'd see if there * was no m.room.name event. * @return {string} The calculated room name. */ function calculateRoomName(room, userId, ignoreRoomNameEvent) { if (!ignoreRoomNameEvent) { // check for an alias, if any. for now, assume first alias is the // official one. const mRoomName = room.currentState.getStateEvents("m.room.name", ""); if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) { return mRoomName.getContent().name; } } let alias = room.getCanonicalAlias(); if (!alias) { const aliases = room.getAltAliases(); if (aliases.length) { alias = aliases[0]; } } if (alias) { return alias; } const joinedMemberCount = room.currentState.getJoinedMemberCount(); const invitedMemberCount = room.currentState.getInvitedMemberCount(); // -1 because these numbers include the syncing user const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; // get members that are NOT ourselves and are actually in the room. let otherNames = null; if (room._summaryHeroes) { // if we have a summary, the member state events // should be in the room state otherNames = room._summaryHeroes.map((userId) => { const member = room.getMember(userId); return member ? member.name : userId; }); } else { let otherMembers = room.currentState.getMembers().filter((m) => { return m.userId !== userId && (m.membership === "invite" || m.membership === "join"); }); // make sure members have stable order otherMembers.sort((a, b) => a.userId.localeCompare(b.userId)); // only 5 first members, immitate _summaryHeroes otherMembers = otherMembers.slice(0, 5); otherNames = otherMembers.map((m) => m.name); } if (inviteJoinCount) { return memberNamesToRoomName(otherNames, inviteJoinCount); } const myMembership = room.getMyMembership(); // if I have created a room and invited people throuh // 3rd party invites if (myMembership == 'join') { const thirdPartyInvites = room.currentState.getStateEvents("m.room.third_party_invite"); if (thirdPartyInvites && thirdPartyInvites.length) { const thirdPartyNames = thirdPartyInvites.map((i) => { return i.getContent().display_name; }); return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`; } } // let's try to figure out who was here before let leftNames = otherNames; // if we didn't have heroes, try finding them in the room state if (!leftNames.length) { leftNames = room.currentState.getMembers().filter((m) => { return m.userId !== userId && m.membership !== "invite" && m.membership !== "join"; }).map((m) => m.name); } if (leftNames.length) { return `Empty room (was ${memberNamesToRoomName(leftNames)})`; } else { return "Empty room"; } } function memberNamesToRoomName(names, count = (names.length + 1)) { const countWithoutMe = count - 1; if (!names.length) { return "Empty room"; } else if (names.length === 1 && countWithoutMe <= 1) { return names[0]; } else if (names.length === 2 && countWithoutMe <= 2) { return `${names[0]} and ${names[1]}`; } else { const plural = countWithoutMe > 1; if (plural) { return `${names[0]} and ${countWithoutMe} others`; } else { return `${names[0]} and 1 other`; } } } /** * Fires when an event we had previously received is redacted. * * (Note this is *not* fired when the redaction happens before we receive the * event). * * @event module:client~MatrixClient#"Room.redaction" * @param {MatrixEvent} event The matrix redaction event * @param {Room} room The room containing the redacted event */ /** * Fires when an event that was previously redacted isn't anymore. * This happens when the redaction couldn't be sent and * was subsequently cancelled by the user. Redactions have a local echo * which is undone in this scenario. * * @event module:client~MatrixClient#"Room.redactionCancelled" * @param {MatrixEvent} event The matrix redaction event that was cancelled. * @param {Room} room The room containing the unredacted event */ /** * Fires whenever the name of a room is updated. * @event module:client~MatrixClient#"Room.name" * @param {Room} room The room whose Room.name was updated. * @example * matrixClient.on("Room.name", function(room){ * var newName = room.name; * }); */ /** * Fires whenever a receipt is received for a room * @event module:client~MatrixClient#"Room.receipt" * @param {event} event The receipt event * @param {Room} room The room whose receipts was updated. * @example * matrixClient.on("Room.receipt", function(event, room){ * var receiptContent = event.getContent(); * }); */ /** * Fires whenever a room's tags are updated. * @event module:client~MatrixClient#"Room.tags" * @param {event} event The tags event * @param {Room} room The room whose Room.tags was updated. * @example * matrixClient.on("Room.tags", function(event, room){ * var newTags = event.getContent().tags; * if (newTags["favourite"]) showStar(room); * }); */ /** * Fires whenever a room's account_data is updated. * @event module:client~MatrixClient#"Room.accountData" * @param {event} event The account_data event * @param {Room} room The room whose account_data was updated. * @param {MatrixEvent} prevEvent The event being replaced by * the new account data, if known. * @example * matrixClient.on("Room.accountData", function(event, room, oldEvent){ * if (event.getType() === "m.room.colorscheme") { * applyColorScheme(event.getContents()); * } * }); */ /** * Fires when the status of a transmitted event is updated. * *

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

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

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

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

    This event is raised to reflect each of the transitions above. * * @event module:client~MatrixClient#"Room.localEchoUpdated" * * @param {MatrixEvent} event The matrix event which has been updated * * @param {Room} room The room containing the redacted event * * @param {string} oldEventId The previous event id (the temporary event id, * except when updating a successfully-sent event when its echo arrives) * * @param {EventStatus} oldStatus The previous event status. */ /** * Fires when the logged in user's membership in the room is updated. * * @event module:models/room~Room#"Room.myMembership" * @param {Room} room The room in which the membership has been updated * @param {string} membership The new membership value * @param {string} prevMembership The previous membership value */ matrix-js-sdk-9.11.0/src/models/search-result.js000066400000000000000000000034441403504662500215020ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module models/search-result */ import * as utils from "../utils"; import {EventContext} from "./event-context"; /** * 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 */ export 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); }; matrix-js-sdk-9.11.0/src/models/user.js000066400000000000000000000220571403504662500177000ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module models/user */ import * as utils from "../utils"; import {EventEmitter} from "events"; /** * Construct a new User. A User must have an ID and can optionally have extra * information associated with it. * @constructor * @param {string} userId Required. The ID of this user. * @prop {string} userId The ID of the user. * @prop {Object} info The info object supplied in the constructor. * @prop {string} displayName The 'displayname' of the user if known. * @prop {string} avatarUrl The 'avatar_url' of the user if known. * @prop {string} presence The presence enum if known. * @prop {string} presenceStatusMsg The presence status message if known. * @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted * proactively with the server, or we saw a message from the user * @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last * received presence data for this user. We can subtract * lastActiveAgo from this to approximate an absolute value for * when a user was last active. * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be * an approximation and that the user should be seen as active 'now' * @prop {string} _unstable_statusMessage The status message for the user, if known. This is * different from the presenceStatusMsg in that this is not tied to * the user's presence, and should be represented differently. * @prop {Object} events The events describing this user. * @prop {MatrixEvent} events.presence The m.presence event for this user. */ export function User(userId) { this.userId = userId; this.presence = "offline"; this.presenceStatusMsg = null; this._unstable_statusMessage = ""; this.displayName = userId; this.rawDisplayName = userId; this.avatarUrl = null; this.lastActiveAgo = 0; this.lastPresenceTs = 0; this.currentlyActive = false; this.events = { presence: null, profile: null, }; this._updateModifiedTime(); } utils.inherits(User, EventEmitter); /** * Update this User with the given presence event. May fire "User.presence", * "User.avatarUrl" and/or "User.displayName" if this event updates this user's * properties. * @param {MatrixEvent} event The m.presence event. * @fires module:client~MatrixClient#event:"User.presence" * @fires module:client~MatrixClient#event:"User.displayName" * @fires module:client~MatrixClient#event:"User.avatarUrl" */ User.prototype.setPresenceEvent = function(event) { if (event.getType() !== "m.presence") { return; } const firstFire = this.events.presence === null; this.events.presence = event; const eventsToFire = []; if (event.getContent().presence !== this.presence || firstFire) { eventsToFire.push("User.presence"); } if (event.getContent().avatar_url && event.getContent().avatar_url !== this.avatarUrl) { eventsToFire.push("User.avatarUrl"); } if (event.getContent().displayname && event.getContent().displayname !== this.displayName) { eventsToFire.push("User.displayName"); } if (event.getContent().currently_active !== undefined && event.getContent().currently_active !== this.currentlyActive) { eventsToFire.push("User.currentlyActive"); } this.presence = event.getContent().presence; eventsToFire.push("User.lastPresenceTs"); if (event.getContent().status_msg) { this.presenceStatusMsg = event.getContent().status_msg; } if (event.getContent().displayname) { this.displayName = event.getContent().displayname; } if (event.getContent().avatar_url) { this.avatarUrl = event.getContent().avatar_url; } this.lastActiveAgo = event.getContent().last_active_ago; this.lastPresenceTs = Date.now(); this.currentlyActive = event.getContent().currently_active; this._updateModifiedTime(); for (let i = 0; i < eventsToFire.length; i++) { this.emit(eventsToFire[i], event, this); } }; /** * Manually set this user's display name. No event is emitted in response to this * as there is no underlying MatrixEvent to emit with. * @param {string} name The new display name. */ User.prototype.setDisplayName = function(name) { const oldName = this.displayName; if (typeof name === "string") { this.displayName = name; } else { this.displayName = undefined; } if (name !== oldName) { this._updateModifiedTime(); } }; /** * Manually set this user's non-disambiguated display name. No event is emitted * in response to this as there is no underlying MatrixEvent to emit with. * @param {string} name The new display name. */ User.prototype.setRawDisplayName = function(name) { if (typeof name === "string") { this.rawDisplayName = name; } else { this.rawDisplayName = undefined; } }; /** * Manually set this user's avatar URL. No event is emitted in response to this * as there is no underlying MatrixEvent to emit with. * @param {string} url The new avatar URL. */ User.prototype.setAvatarUrl = function(url) { const oldUrl = this.avatarUrl; this.avatarUrl = url; if (url !== oldUrl) { this._updateModifiedTime(); } }; /** * Update the last modified time to the current time. */ User.prototype._updateModifiedTime = function() { this._modified = Date.now(); }; /** * Get the timestamp when this User was last updated. This timestamp is * updated when this User receives a new Presence event which has updated a * property on this object. It is updated before firing events. * @return {number} The timestamp */ User.prototype.getLastModifiedTime = function() { return this._modified; }; /** * Get the absolute timestamp when this User was last known active on the server. * It is *NOT* accurate if this.currentlyActive is true. * @return {number} The timestamp */ User.prototype.getLastActiveTs = function() { return this.lastPresenceTs - this.lastActiveAgo; }; /** * Manually set the user's status message. * @param {MatrixEvent} event The im.vector.user_status event. * @fires module:client~MatrixClient#event:"User._unstable_statusMessage" */ User.prototype._unstable_updateStatusMessage = function(event) { if (!event.getContent()) this._unstable_statusMessage = ""; else this._unstable_statusMessage = event.getContent()["status"]; this._updateModifiedTime(); this.emit("User._unstable_statusMessage", this); }; /** * Fires whenever any user's lastPresenceTs changes, * ie. whenever any presence event is received for a user. * @event module:client~MatrixClient#"User.lastPresenceTs" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {User} user The user whose User.lastPresenceTs changed. * @example * matrixClient.on("User.lastPresenceTs", function(event, user){ * var newlastPresenceTs = user.lastPresenceTs; * }); */ /** * Fires whenever any user's presence changes. * @event module:client~MatrixClient#"User.presence" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {User} user The user whose User.presence changed. * @example * matrixClient.on("User.presence", function(event, user){ * var newPresence = user.presence; * }); */ /** * Fires whenever any user's currentlyActive changes. * @event module:client~MatrixClient#"User.currentlyActive" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {User} user The user whose User.currentlyActive changed. * @example * matrixClient.on("User.currentlyActive", function(event, user){ * var newCurrentlyActive = user.currentlyActive; * }); */ /** * Fires whenever any user's display name changes. * @event module:client~MatrixClient#"User.displayName" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {User} user The user whose User.displayName changed. * @example * matrixClient.on("User.displayName", function(event, user){ * var newName = user.displayName; * }); */ /** * Fires whenever any user's avatar URL changes. * @event module:client~MatrixClient#"User.avatarUrl" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {User} user The user whose User.avatarUrl changed. * @example * matrixClient.on("User.avatarUrl", function(event, user){ * var newUrl = user.avatarUrl; * }); */ matrix-js-sdk-9.11.0/src/pushprocessor.js000066400000000000000000000351201403504662500203510ustar00rootroot00000000000000/* 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, isNullOrUndefined} from "./utils"; import {logger} from './logger'; /** * @module pushprocessor */ const RULEKINDS_IN_ORDER = ['override', 'content', 'room', 'sender', 'underride']; // The default override rules to apply to the push rules that arrive from the server. // 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", }, { kind: "event_match", key: "state_key", pattern: "", }, ], 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 */ export function PushProcessor(client) { const cachedGlobToRegex = { // $glob: RegExp, }; const matchingRuleFromKindSet = (ev, kindset) => { for (let ruleKindIndex = 0; ruleKindIndex < RULEKINDS_IN_ORDER.length; ++ruleKindIndex) { const kind = RULEKINDS_IN_ORDER[ruleKindIndex]; const ruleset = kindset[kind]; if (!ruleset) { continue; } for (let ruleIndex = 0; ruleIndex < ruleset.length; ++ruleIndex) { const rule = ruleset[ruleIndex]; if (!rule.enabled) { continue; } const rawrule = templateRuleToRaw(kind, rule); if (!rawrule) { continue; } if (this.ruleMatchesEvent(rawrule, ev)) { rule.kind = kind; return rule; } } } return null; }; const templateRuleToRaw = function(kind, tprule) { 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; } return rawrule; }; const eventFulfillsCondition = function(cond, ev) { const condition_functions = { "event_match": eventFulfillsEventMatchCondition, "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 eventFulfillsEventMatchCondition = function(cond, ev) { if (!cond.key) { return false; } const val = valueForDottedKey(cond.key, ev); if (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 (isNullOrUndefined(val[thisPart])) { return null; } val = val[thisPart]; } return val; }; const matchingRuleForEventWithRulesets = function(ev, rulesets) { if (!rulesets) { return null; } if (ev.getSender() === client.credentials.userId) { return null; } 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; }; 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) { return pushActionsForEventAndRulesets(ev, client.pushRules); }; /** * 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 ['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; }; /** * Rewrites conditions on a client's push rules to match the defaults * where applicable. Useful for upgrading push rules to more strict * conditions when the server is falling behind on defaults. * @param {object} incomingRules The client's existing push rules * @returns {object} The rewritten rules */ PushProcessor.rewriteDefaultRules = function(incomingRules) { let newRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone // These lines are mostly to make the tests happy. We shouldn't run into these // properties missing in practice. if (!newRules) newRules = {}; if (!newRules.global) newRules.global = {}; if (!newRules.global.override) newRules.global.override = []; // Merge the client-level defaults with the ones from the server const globalOverrides = newRules.global.override; for (const override of DEFAULT_OVERRIDE_RULES) { const existingRule = globalOverrides .find((r) => r.rule_id === override.rule_id); if (existingRule) { // Copy over the actions, default, and conditions. Don't touch the user's // preference. existingRule.default = override.default; existingRule.conditions = override.conditions; existingRule.actions = override.actions; } else { // Add the rule const ruleId = override.rule_id; logger.warn(`Adding default global override for ${ruleId}`); globalOverrides.push(override); } } return newRules; }; /** * @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. */ matrix-js-sdk-9.11.0/src/randomstring.ts000066400000000000000000000024121403504662500201510ustar00rootroot00000000000000/* Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const DIGITS = "0123456789"; export function randomString(len: number): string { return randomStringFrom(len, UPPERCASE + LOWERCASE + DIGITS); } export function randomLowercaseString(len: number): string { return randomStringFrom(len, LOWERCASE); } export function randomUppercaseString(len: number): string { return randomStringFrom(len, UPPERCASE); } function randomStringFrom(len: number, chars: string): string { let ret = ""; for (let i = 0; i < len; ++i) { ret += chars.charAt(Math.floor(Math.random() * chars.length)); } return ret; } matrix-js-sdk-9.11.0/src/realtime-callbacks.js000066400000000000000000000132171403504662500211540ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /* 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. */ import {logger} from './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 */ export function setNow(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. */ export function setTimeout(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 */ export function clearTimeout(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; let 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; } matrix-js-sdk-9.11.0/src/scheduler.js000066400000000000000000000270021403504662500174100ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This is an internal module which manages queuing, scheduling and retrying * of requests. * @module scheduler */ import * as utils from "./utils"; import {logger} from './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}. */ export 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 = utils.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 event that we are trying to send is too large in any way then retrying won't help if (err.name === "M_TOO_LARGE") { return -1; } if (err.name === "M_LIMIT_EXCEEDED") { const waitTime = err.data.retry_after_ms; if (waitTime > 0) { 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. */ matrix-js-sdk-9.11.0/src/service-types.js000066400000000000000000000013341403504662500202340ustar00rootroot00000000000000/* 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. */ export const SERVICE_TYPES = Object.freeze({ IS: 'SERVICE_TYPE_IS', // An Identity Service IM: 'SERVICE_TYPE_IM', // An Integration Manager }); matrix-js-sdk-9.11.0/src/store/000077500000000000000000000000001403504662500162275ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/store/indexeddb-local-backend.js000066400000000000000000000511731403504662500231770ustar00rootroot00000000000000/* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {SyncAccumulator} from "../sync-accumulator"; import * as utils from "../utils"; import * as IndexedDBHelpers from "../indexeddb-helpers"; import {logger} from '../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. */ export 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, }, }, true); }); }, /** * 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(true); 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 utils.promiseTry(() => { 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 utils.promiseTry(() => { 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 utils.promiseTry(() => { 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 utils.promiseTry(() => { 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 utils.promiseTry(() => { 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 utils.promiseTry(() => { 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); }, }; matrix-js-sdk-9.11.0/src/store/indexeddb-remote-backend.js000066400000000000000000000146461403504662500234040ustar00rootroot00000000000000/* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {logger} from '../logger'; import {defer} from '../utils'; /** * 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 */ export 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 = 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); } }, }; matrix-js-sdk-9.11.0/src/store/indexeddb-store-worker.js000066400000000000000000000122131403504662500231530ustar00rootroot00000000000000/* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import {LocalIndexedDBStoreBackend} from "./indexeddb-local-backend.js"; import {logger} from '../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. * */ export 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.then((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, }, }); }); } } matrix-js-sdk-9.11.0/src/store/indexeddb.js000066400000000000000000000302411403504662500205130ustar00rootroot00000000000000/* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /* eslint-disable babel/no-invalid-this */ import {MemoryStore} from "./memory"; import * as utils from "../utils"; import {EventEmitter} from 'events'; import {LocalIndexedDBStoreBackend} from "./indexeddb-local-backend.js"; import {RemoteIndexedDBStoreBackend} from "./indexeddb-remote-backend.js"; import {User} from "../models/user"; import {MatrixEvent} from "../models/event"; import {logger} from '../logger'; /** * This is an internal module. See {@link IndexedDBStore} for the public class. * @module store/indexeddb */ // If this value is too small we'll be writing very often which will cause // noticable stop-the-world pauses. If this value is too big we'll be writing // so infrequently that the /sync size gets bigger on reload. Writing more // often does not affect the length of the pause since the entire /sync // response is persisted each time. const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes /** * Construct a new Indexed Database store, which extends MemoryStore. * * This store functions like a MemoryStore except it periodically persists * the contents of the store to an IndexedDB backend. * * All data is still kept in-memory but can be loaded from disk by calling * startup(). This can make startup times quicker as a complete * sync from the server is not required. This does not reduce memory usage as all * the data is eagerly fetched when startup() is called. *
     * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
     * let store = new IndexedDBStore(opts);
     * await store.startup(); // load from indexed db
     * let client = sdk.createClient({
     *     store: store,
     * });
     * client.startClient();
     * client.on("sync", function(state, prevState, data) {
     *     if (state === "PREPARED") {
     *         console.log("Started up, now with go faster stripes!");
     *     }
     * });
     * 
    * * @constructor * @extends MemoryStore * @param {Object} opts Options object. * @param {Object} opts.indexedDB The Indexed DB interface e.g. * window.indexedDB * @param {string=} opts.dbName Optional database name. The same name must be used * to open the same database. * @param {string=} opts.workerScript Optional URL to a script to invoke a web * worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker * class is provided for this purpose and requires the application to provide a * trivial wrapper script around it. * @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker * object will be used if it exists. * @prop {IndexedDBStoreBackend} backend The backend instance. Call through to * this API if you need to perform specific indexeddb actions like deleting the * database. */ export function IndexedDBStore(opts) { MemoryStore.call(this, opts); if (!opts.indexedDB) { throw new Error('Missing required option: indexedDB'); } if (opts.workerScript) { // try & find a webworker-compatible API let workerApi = opts.workerApi; if (!workerApi) { // default to the global Worker object (which is where it in a browser) workerApi = global.Worker; } this.backend = new RemoteIndexedDBStoreBackend( opts.workerScript, opts.dbName, workerApi, ); } else { this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName); } this.startedUp = false; this._syncTs = 0; // Records the last-modified-time of each user at the last point we saved // the database, such that we can derive the set if users that have been // modified since we last saved. this._userModifiedMap = { // user_id : timestamp }; } utils.inherits(IndexedDBStore, MemoryStore); utils.extend(IndexedDBStore.prototype, EventEmitter.prototype); IndexedDBStore.exists = function(indexedDB, dbName) { return LocalIndexedDBStoreBackend.exists(indexedDB, dbName); }; /** * @return {Promise} Resolved when loaded from indexed db. */ IndexedDBStore.prototype.startup = function() { if (this.startedUp) { logger.log(`IndexedDBStore.startup: already started`); return Promise.resolve(); } logger.log(`IndexedDBStore.startup: connecting to backend`); return this.backend.connect().then(() => { logger.log(`IndexedDBStore.startup: loading presence events`); return this.backend.getUserPresenceEvents(); }).then((userPresenceEvents) => { logger.log(`IndexedDBStore.startup: processing presence events`); userPresenceEvents.forEach(([userId, rawEvent]) => { const u = new User(userId); if (rawEvent) { u.setPresenceEvent(new MatrixEvent(rawEvent)); } this._userModifiedMap[u.userId] = u.getLastModifiedTime(); this.storeUser(u); }); }); }; /** * @return {Promise} Resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ IndexedDBStore.prototype.getSavedSync = degradable(function() { return this.backend.getSavedSync(); }, "getSavedSync"); /** @return {Promise} whether or not the database was newly created in this session. */ IndexedDBStore.prototype.isNewlyCreated = degradable(function() { return this.backend.isNewlyCreated(); }, "isNewlyCreated"); /** * @return {Promise} If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ IndexedDBStore.prototype.getSavedSyncToken = degradable(function() { return this.backend.getNextBatchToken(); }, "getSavedSyncToken"), /** * Delete all data from this store. * @return {Promise} Resolves if the data was deleted from the database. */ IndexedDBStore.prototype.deleteAllData = degradable(function() { MemoryStore.prototype.deleteAllData.call(this); return this.backend.clearDatabase().then(() => { logger.log("Deleted indexeddb data."); }, (err) => { logger.error(`Failed to delete indexeddb data: ${err}`); throw err; }); }); /** * Whether this store would like to save its data * Note that obviously whether the store wants to save or * not could change between calling this function and calling * save(). * * @return {boolean} True if calling save() will actually save * (at the time this function is called). */ IndexedDBStore.prototype.wantsSave = function() { const now = Date.now(); return now - this._syncTs > WRITE_DELAY_MS; }; /** * Possibly write data to the database. * * @param {bool} force True to force a save to happen * @return {Promise} Promise resolves after the write completes * (or immediately if no write is performed) */ IndexedDBStore.prototype.save = function(force) { if (force || this.wantsSave()) { return this._reallySave(); } return Promise.resolve(); }; IndexedDBStore.prototype._reallySave = degradable(function() { this._syncTs = Date.now(); // set now to guard against multi-writes // work out changed users (this doesn't handle deletions but you // can't 'delete' users as they are just presence events). const userTuples = []; for (const u of this.getUsers()) { if (this._userModifiedMap[u.userId] === u.getLastModifiedTime()) continue; if (!u.events.presence) continue; userTuples.push([u.userId, u.events.presence.event]); // note that we've saved this version of the user this._userModifiedMap[u.userId] = u.getLastModifiedTime(); } return this.backend.syncToDatabase(userTuples); }); IndexedDBStore.prototype.setSyncData = degradable(function(syncData) { return this.backend.setSyncData(syncData); }, "setSyncData"); /** * Returns the out-of-band membership events for this room that * were previously loaded. * @param {string} roomId * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members * @returns {null} in case the members for this room haven't been stored yet */ IndexedDBStore.prototype.getOutOfBandMembers = degradable(function(roomId) { return this.backend.getOutOfBandMembers(roomId); }, "getOutOfBandMembers"); /** * Stores the out-of-band membership events for this room. Note that * it still makes sense to store an empty array as the OOB status for the room is * marked as fetched, and getOutOfBandMembers will return an empty array instead of null * @param {string} roomId * @param {event[]} membershipEvents the membership events to store * @returns {Promise} when all members have been stored */ IndexedDBStore.prototype.setOutOfBandMembers = degradable(function( roomId, membershipEvents, ) { MemoryStore.prototype.setOutOfBandMembers.call(this, roomId, membershipEvents); return this.backend.setOutOfBandMembers(roomId, membershipEvents); }, "setOutOfBandMembers"); IndexedDBStore.prototype.clearOutOfBandMembers = degradable(function(roomId) { MemoryStore.prototype.clearOutOfBandMembers.call(this); return this.backend.clearOutOfBandMembers(roomId); }, "clearOutOfBandMembers"); IndexedDBStore.prototype.getClientOptions = degradable(function() { return this.backend.getClientOptions(); }, "getClientOptions"); IndexedDBStore.prototype.storeClientOptions = degradable(function(options) { MemoryStore.prototype.storeClientOptions.call(this, options); return this.backend.storeClientOptions(options); }, "storeClientOptions"); /** * All member functions of `IndexedDBStore` that access the backend use this wrapper to * watch for failures after initial store startup, including `QuotaExceededError` as * free disk space changes, etc. * * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore` * in place so that the current operation and all future ones are in-memory only. * * @param {Function} func The degradable work to do. * @param {String} fallback The method name for fallback. * @returns {Function} A wrapped member function. */ function degradable(func, fallback) { return async function(...args) { try { return await func.call(this, ...args); } catch (e) { logger.error("IndexedDBStore failure, degrading to MemoryStore", e); this.emit("degraded", e); try { // We try to delete IndexedDB after degrading since this store is only a // cache (the app will still function correctly without the data). // It's possible that deleting repair IndexedDB for the next app load, // potenially by making a little more space available. logger.log("IndexedDBStore trying to delete degraded data"); await this.backend.clearDatabase(); logger.log("IndexedDBStore delete after degrading succeeeded"); } catch (e) { logger.warn("IndexedDBStore delete after degrading failed", e); } // Degrade the store from being an instance of `IndexedDBStore` to instead be // an instance of `MemoryStore` so that future API calls use the memory path // directly and skip IndexedDB entirely. This should be safe as // `IndexedDBStore` already extends from `MemoryStore`, so we are making the // store become its parent type in a way. The mutator methods of // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are // not overridden at all). Object.setPrototypeOf(this, MemoryStore.prototype); if (fallback) { return await MemoryStore.prototype[fallback].call(this, ...args); } } }; } matrix-js-sdk-9.11.0/src/store/memory.js000066400000000000000000000316361403504662500201060ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This is an internal module. See {@link MemoryStore} for the public class. * @module store/memory */ import {User} from "../models/user"; import * as utils from "../utils"; function isValidFilterId(filterId) { const isValidStr = typeof filterId === "string" && !!filterId && filterId !== "undefined" && // exclude these as we've serialized undefined in localStorage before filterId !== "null"; return isValidStr || typeof filterId === "number"; } /** * 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. */ export function MemoryStore(opts) { opts = opts || {}; this.rooms = { // roomId: Room }; this.groups = { // groupId: Group }; this.users = { // userId: User }; this.syncToken = null; this.filters = { // userId: { // filterId: Filter // } }; this.accountData = { // type : content }; this.localStorage = opts.localStorage; this._oobMembers = { // roomId: [member events] }; this._clientOptions = {}; } 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; } const key = "mxjssdk_memory_filter_" + filterName; // XXX Storage.getItem doesn't throw ... // or are we using something different // than window.localStorage in some cases // that does throw? // that would be very naughty try { const value = this.localStorage.getItem(key); if (isValidFilterId(value)) { return value; } } 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; } const key = "mxjssdk_memory_filter_" + filterName; try { if (isValidFilterId(filterId)) { this.localStorage.setItem(key, filterId); } else { this.localStorage.removeItem(key); } } 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[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[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 = { // 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(); }, }; matrix-js-sdk-9.11.0/src/store/session/000077500000000000000000000000001403504662500177125ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/store/session/webstorage.js000066400000000000000000000210171403504662500224130ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 New Vector Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * @module store/session/webstorage */ import * as utils from "../../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. */ export 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); } } matrix-js-sdk-9.11.0/src/store/stub.js000066400000000000000000000141621403504662500175460ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This is an internal module. * @module store/stub */ /** * Construct a stub store. This does no-ops on most store methods. * @constructor */ export 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(); }, }; matrix-js-sdk-9.11.0/src/sync-accumulator.js000066400000000000000000000626141403504662500207330ustar00rootroot00000000000000/* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This is an internal module. See {@link SyncAccumulator} for the public class. * @module sync-accumulator */ import {logger} from './logger'; import {deepCopy} from "./utils"; /** * 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. */ export 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; this.accountData = { //$event_type: Object }; this.inviteRooms = { //$roomId: { ... sync 'invite' json data ... } }; this.joinRooms = { //$roomId: { // _currentState: { $event_type: { $state_key: json } }, // _timeline: [ // { event: $event, token: null|token }, // { event: $event, token: null|token }, // { event: $event, token: null|token }, // ... // ], // _summary: { // m.heroes: [ $user_id ], // m.joined_member_count: $count, // m.invited_member_count: $count // }, // _accountData: { $event_type: json }, // _unreadNotifications: { ... unread_notifications JSON ... }, // _readReceipts: { $user_id: { data: $json, eventId: $event_id }} //} }; // the /sync token which corresponds to the last time rooms were // accumulated. We remember this so that any caller can obtain a // coherent /sync response and know at what point they should be // streaming from without losing events. this.nextBatch = null; // { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } } this.groups = { invite: {}, join: {}, leave: {}, }; } accumulate(syncResponse, fromDatabase) { this._accumulateRooms(syncResponse, fromDatabase); 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[e.type] = e; }); } /** * Accumulate incremental /sync room data. * @param {Object} syncResponse the complete /sync JSON * @param {boolean} fromDatabase True if the sync response is one saved to the database */ _accumulateRooms(syncResponse, fromDatabase) { if (!syncResponse.rooms) { return; } if (syncResponse.rooms.invite) { Object.keys(syncResponse.rooms.invite).forEach((roomId) => { this._accumulateRoom( roomId, "invite", syncResponse.rooms.invite[roomId], fromDatabase, ); }); } if (syncResponse.rooms.join) { Object.keys(syncResponse.rooms.join).forEach((roomId) => { this._accumulateRoom( roomId, "join", syncResponse.rooms.join[roomId], fromDatabase, ); }); } if (syncResponse.rooms.leave) { Object.keys(syncResponse.rooms.leave).forEach((roomId) => { this._accumulateRoom( roomId, "leave", syncResponse.rooms.leave[roomId], fromDatabase, ); }); } } _accumulateRoom(roomId, category, data, fromDatabase) { // 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, fromDatabase); 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, fromDatabase) { // 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 let transformedEvent; if (!fromDatabase) { transformedEvent = Object.assign({}, e); if (transformedEvent.unsigned !== undefined) { transformedEvent.unsigned = Object.assign({}, transformedEvent.unsigned); } const age = e.unsigned ? e.unsigned.age : e.age; if (age !== undefined) transformedEvent._localTs = Date.now() - age; } else { transformedEvent = e; } currentData._timeline.push({ event: transformedEvent, 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(). * @param {boolean} forDatabase True to generate a sync to be saved to storage * @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(forDatabase) { 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; } let transformedEvent; if (!forDatabase && msgData.event._localTs) { // This means we have to copy each event so we can fix it up to // set a correct 'age' parameter whilst keeping the local timestamp // on our stored event. If this turns out to be a bottleneck, it could // be optimised either by doing this in the main process after the data // has been structured-cloned to go between the worker & main process, // or special-casing data from saved syncs to read the local timstamp // directly rather than turning it into age to then immediately be // transformed back again into a local timestamp. transformedEvent = Object.assign({}, msgData.event); if (transformedEvent.unsigned !== undefined) { transformedEvent.unsigned = Object.assign({}, transformedEvent.unsigned); } delete transformedEvent._localTs; transformedEvent.unsigned = transformedEvent.unsigned || {}; transformedEvent.unsigned.age = Date.now() - msgData.event._localTs; } else { transformedEvent = msgData.event; } roomJson.timeline.events.push(transformedEvent); }); // 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 = 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 = []; Object.keys(this.accountData).forEach((evType) => { accData.push(this.accountData[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; } matrix-js-sdk-9.11.0/src/sync.js000066400000000000000000001765141403504662500164230ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /* * TODO: * This class mainly serves to take all the syncing logic out of client.js and * into a separate file. It's all very fluid, and this class gut wrenches a lot * of MatrixClient props (e.g. _http). Given we want to support WebSockets as * an alternative syncing API, we may want to have a proper syncing interface * for HTTP and WS at some point. */ import {User} from "./models/user"; import {Room} from "./models/room"; import {Group} from "./models/group"; import * as utils from "./utils"; import {Filter} from "./filter"; import {EventTimeline} from "./models/event-timeline"; import {PushProcessor} from "./pushprocessor"; import {logger} from './logger'; import {InvalidStoreError} from './errors'; const DEBUG = true; // /sync requests allow you to set a timeout= but the request may continue // beyond that and wedge forever, so we need to track how long we are willing // to keep open the connection. This constant is *ADDED* to the timeout= value // to determine the max time we're willing to wait. const BUFFER_PERIOD_MS = 80 * 1000; // Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed // to RECONNECTING. This is needed to inform the client of server issues when the // keepAlive is successful but the server /sync fails. const FAILED_SYNC_ERROR_THRESHOLD = 3; function getFilterName(userId, suffix) { // scope this on the user ID because people may login on many accounts // and they all need to be stored! return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : ""); } function debuglog(...params) { if (!DEBUG) { return; } logger.log(...params); } /** * Internal class - unstable. * Construct an entity which is able to sync with a homeserver. * @constructor * @param {MatrixClient} client The matrix client instance to use. * @param {Object} opts Config options * @param {module:crypto=} opts.crypto Crypto manager * @param {Function=} opts.canResetEntireTimeline A function which is called * with a room ID and returns a boolean. It should return 'true' if the SDK can * SAFELY remove events from this room. It may not be safe to remove events if * there are other references to the timelines for this room. * Default: returns false. * @param {Boolean=} opts.disablePresence True to perform syncing without automatically * updating presence. */ export function SyncApi(client, opts) { this.client = client; opts = opts || {}; opts.initialSyncLimit = ( opts.initialSyncLimit === undefined ? 8 : opts.initialSyncLimit ); opts.resolveInvitesToProfiles = opts.resolveInvitesToProfiles || false; opts.pollTimeout = opts.pollTimeout || (30 * 1000); opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; if (!opts.canResetEntireTimeline) { opts.canResetEntireTimeline = function(roomId) { return false; }; } this.opts = opts; this._peekRoom = null; this._currentSyncRequest = null; this._syncState = null; this._syncStateData = null; // additional data (eg. error object for failed sync) this._catchingUp = false; this._running = false; this._keepAliveTimer = null; this._connectionReturnedDefer = null; this._notifEvents = []; // accumulator of sync events in the current sync response this._failedSyncCount = 0; // Number of consecutive failed /sync requests this._storeIsInvalid = false; // flag set if the store needs to be cleared before we can start if (client.getNotifTimelineSet()) { client.reEmitter.reEmit(client.getNotifTimelineSet(), ["Room.timeline", "Room.timelineReset"]); } } /** * @param {string} roomId * @return {Room} */ SyncApi.prototype.createRoom = function(roomId) { const client = this.client; const { timelineSupport, unstableClientRelationAggregation, } = client; const room = new Room(roomId, client, client.getUserId(), { lazyLoadMembers: this.opts.lazyLoadMembers, pendingEventOrdering: this.opts.pendingEventOrdering, timelineSupport, unstableClientRelationAggregation, }); client.reEmitter.reEmit(room, ["Room.name", "Room.timeline", "Room.redaction", "Room.redactionCancelled", "Room.receipt", "Room.tags", "Room.timelineReset", "Room.localEchoUpdated", "Room.accountData", "Room.myMembership", "Room.replaceEvent", ]); this._registerStateListeners(room); return room; }; /** * @param {string} groupId * @return {Group} */ SyncApi.prototype.createGroup = function(groupId) { const client = this.client; const group = new Group(groupId); client.reEmitter.reEmit(group, ["Group.profile", "Group.myMembership"]); client.store.storeGroup(group); return group; }; /** * @param {Room} room * @private */ SyncApi.prototype._registerStateListeners = function(room) { const client = this.client; // we need to also re-emit room state and room member events, so hook it up // to the client now. We need to add a listener for RoomState.members in // order to hook them correctly. (TODO: find a better way?) client.reEmitter.reEmit(room.currentState, [ "RoomState.events", "RoomState.members", "RoomState.newMember", ]); room.currentState.on("RoomState.newMember", function(event, state, member) { member.user = client.getUser(member.userId); client.reEmitter.reEmit( member, [ "RoomMember.name", "RoomMember.typing", "RoomMember.powerLevel", "RoomMember.membership", ], ); }); }; /** * @param {Room} room * @private */ SyncApi.prototype._deregisterStateListeners = function(room) { // could do with a better way of achieving this. room.currentState.removeAllListeners("RoomState.events"); room.currentState.removeAllListeners("RoomState.members"); room.currentState.removeAllListeners("RoomState.newMember"); }; /** * Sync rooms the user has left. * @return {Promise} Resolved when they've been added to the store. */ SyncApi.prototype.syncLeftRooms = function() { const client = this.client; const self = this; // grab a filter with limit=1 and include_leave=true const filter = new Filter(this.client.credentials.userId); filter.setTimelineLimit(1); filter.setIncludeLeaveRooms(true); const localTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS; const qps = { timeout: 0, // don't want to block since this is a single isolated req }; return client.getOrCreateFilter( getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter, ).then(function(filterId) { qps.filter = filterId; return client._http.authedRequest( undefined, "GET", "/sync", qps, undefined, localTimeoutMs, ); }).then(function(data) { let leaveRooms = []; if (data.rooms && data.rooms.leave) { leaveRooms = self._mapSyncResponseToRoomArray(data.rooms.leave); } const rooms = []; leaveRooms.forEach(function(leaveObj) { const room = leaveObj.room; rooms.push(room); if (!leaveObj.isBrandNewRoom) { // the intention behind syncLeftRooms is to add in rooms which were // *omitted* from the initial /sync. Rooms the user were joined to // but then left whilst the app is running will appear in this list // and we do not want to bother with them since they will have the // current state already (and may get dupe messages if we add // yet more timeline events!), so skip them. // NB: When we persist rooms to localStorage this will be more // complicated... return; } leaveObj.timeline = leaveObj.timeline || {}; const timelineEvents = self._mapSyncEventsFormat(leaveObj.timeline, room); const stateEvents = self._mapSyncEventsFormat(leaveObj.state, room); // set the back-pagination token. Do this *before* adding any // events so that clients can start back-paginating. room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS); self._processRoomEvents(room, stateEvents, timelineEvents); room.recalculate(); client.store.storeRoom(room); client.emit("Room", room); self._processEventsForNotifs(room, timelineEvents); }); return rooms; }); }; /** * Peek into a room. This will result in the room in question being synced so it * is accessible via getRooms(). Live updates for the room will be provided. * @param {string} roomId The room ID to peek into. * @return {Promise} A promise which resolves once the room has been added to the * store. */ SyncApi.prototype.peek = function(roomId) { if (this._peekRoom && this._peekRoom.roomId === roomId) { return Promise.resolve(this._peekRoom); } const client = this.client; this._peekRoom = this.createRoom(roomId); return this.client.roomInitialSync(roomId, 20).then((response) => { // make sure things are init'd response.messages = response.messages || {}; response.messages.chunk = response.messages.chunk || []; response.state = response.state || []; // FIXME: Mostly duplicated from _processRoomEvents but not entirely // because "state" in this API is at the BEGINNING of the chunk const oldStateEvents = utils.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) { this._peekRoom.oldState.paginationToken = response.messages.start; } // set the state of the room to as it was after the timeline executes this._peekRoom.oldState.setStateEvents(oldStateEvents); this._peekRoom.currentState.setStateEvents(stateEvents); this._resolveInvites(this._peekRoom); this._peekRoom.recalculate(); // roll backwards to diverge old state. addEventsToTimeline // will overwrite the pagination token, so make sure it overwrites // it with the right thing. this._peekRoom.addEventsToTimeline(messages.reverse(), true, this._peekRoom.getLiveTimeline(), response.messages.start); client.store.storeRoom(this._peekRoom); client.emit("Room", this._peekRoom); this._peekPoll(this._peekRoom); return this._peekRoom; }); }; /** * Stop polling for updates in the peeked room. NOPs if there is no room being * peeked. */ SyncApi.prototype.stopPeeking = function() { this._peekRoom = null; }; /** * Do a peek room poll. * @param {Room} peekRoom * @param {string?} token from= token */ SyncApi.prototype._peekPoll = function(peekRoom, token) { if (this._peekRoom !== peekRoom) { debuglog("Stopped peeking in room %s", peekRoom.roomId); return; } const self = this; // FIXME: gut wrenching; hard-coded timeout values this.client._http.authedRequest(undefined, "GET", "/events", { room_id: peekRoom.roomId, timeout: 30 * 1000, from: token, }, undefined, 50 * 1000).then(function(res) { if (self._peekRoom !== peekRoom) { debuglog("Stopped peeking in room %s", peekRoom.roomId); return; } // We have a problem that we get presence both from /events and /sync // however, /sync only returns presence for users in rooms // you're actually joined to. // in order to be sure to get presence for all of the users in the // peeked room, we handle presence explicitly here. This may result // in duplicate presence events firing for some users, which is a // performance drain, but such is life. // XXX: copypasted from /sync until we can kill this minging v1 stuff. res.chunk.filter(function(e) { return e.type === "m.presence"; }).map(self.client.getEventMapper()).forEach(function(presenceEvent) { let user = self.client.store.getUser(presenceEvent.getContent().user_id); if (user) { user.setPresenceEvent(presenceEvent); } else { user = createNewUser(self.client, presenceEvent.getContent().user_id); user.setPresenceEvent(presenceEvent); self.client.store.storeUser(user); } self.client.emit("event", presenceEvent); }); // strip out events which aren't for the given room_id (e.g presence) // and also ephemeral events (which we're assuming is anything without // and event ID because the /events API doesn't separate them). const events = res.chunk.filter(function(e) { return e.room_id === peekRoom.roomId && e.event_id; }).map(self.client.getEventMapper()); peekRoom.addLiveEvents(events); self._peekPoll(peekRoom, res.end); }, function(err) { logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err); setTimeout(function() { self._peekPoll(peekRoom, token); }, 30 * 1000); }); }; /** * Returns the current state of this sync object * @see module:client~MatrixClient#event:"sync" * @return {?String} */ SyncApi.prototype.getSyncState = function() { return this._syncState; }; /** * Returns the additional data object associated with * the current sync state, or null if there is no * such data. * Sync errors, if available, are put in the 'error' key of * this object. * @return {?Object} */ SyncApi.prototype.getSyncStateData = function() { return this._syncStateData; }; SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise, err) { // Wait for the saved sync to complete - we send the pushrules and filter requests // before the saved sync has finished so they can run in parallel, but only process // the results after the saved sync is done. Equivalently, we wait for it to finish // before reporting failures from these functions. await savedSyncPromise; const keepaliveProm = this._startKeepAlives(); this._updateSyncState("ERROR", { error: err }); await keepaliveProm; }; /** * Is the lazy loading option different than in previous session? * @param {bool} lazyLoadMembers current options for lazy loading * @return {bool} whether or not the option has changed compared to the previous session */ SyncApi.prototype._wasLazyLoadingToggled = async function(lazyLoadMembers) { lazyLoadMembers = !!lazyLoadMembers; // assume it was turned off before // if we don't know any better let lazyLoadMembersBefore = false; const isStoreNewlyCreated = await this.client.store.isNewlyCreated(); if (!isStoreNewlyCreated) { const prevClientOptions = await this.client.store.getClientOptions(); if (prevClientOptions) { lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers; } return lazyLoadMembersBefore !== lazyLoadMembers; } return false; }; SyncApi.prototype._shouldAbortSync = function(error) { if (error.errcode === "M_UNKNOWN_TOKEN") { // The logout already happened, we just need to stop. logger.warn("Token no longer valid - assuming logout"); this.stop(); return true; } return false; }; /** * Main entry point */ SyncApi.prototype.sync = function() { const client = this.client; const self = this; this._running = true; if (global.window) { this._onOnlineBound = this._onOnline.bind(this); global.window.addEventListener("online", this._onOnlineBound, false); } let savedSyncPromise = Promise.resolve(); let savedSyncToken = null; // We need to do one-off checks before we can begin the /sync loop. // These are: // 1) We need to get push rules so we can check if events should bing as we get // them from /sync. // 2) We need to get/create a filter which we can use for /sync. // 3) We need to check the lazy loading option matches what was used in the // stored sync. If it doesn't, we can't use the stored sync. async function getPushRules() { try { debuglog("Getting push rules..."); const result = await client.getPushRules(); debuglog("Got push rules"); client.pushRules = result; } catch (err) { logger.error("Getting push rules failed", err); if (self._shouldAbortSync(err)) return; // wait for saved sync to complete before doing anything else, // otherwise the sync state will end up being incorrect debuglog("Waiting for saved sync before retrying push rules..."); await self.recoverFromSyncStartupError(savedSyncPromise, err); getPushRules(); return; } checkLazyLoadStatus(); // advance to the next stage } function buildDefaultFilter() { const filter = new Filter(client.credentials.userId); filter.setTimelineLimit(self.opts.initialSyncLimit); return filter; } const checkLazyLoadStatus = async () => { debuglog("Checking lazy load status..."); if (this.opts.lazyLoadMembers && client.isGuest()) { this.opts.lazyLoadMembers = false; } if (this.opts.lazyLoadMembers) { debuglog("Checking server lazy load support..."); const supported = await client.doesServerSupportLazyLoading(); if (supported) { debuglog("Enabling lazy load on sync filter..."); if (!this.opts.filter) { this.opts.filter = buildDefaultFilter(); } this.opts.filter.setLazyLoadMembers(true); } else { debuglog("LL: lazy loading requested but not supported " + "by server, so disabling"); this.opts.lazyLoadMembers = false; } } // need to vape the store when enabling LL and wasn't enabled before debuglog("Checking whether lazy loading has changed in store..."); const shouldClear = await this._wasLazyLoadingToggled(this.opts.lazyLoadMembers); if (shouldClear) { this._storeIsInvalid = true; const reason = InvalidStoreError.TOGGLED_LAZY_LOADING; const error = new InvalidStoreError(reason, !!this.opts.lazyLoadMembers); this._updateSyncState("ERROR", { error }); // bail out of the sync loop now: the app needs to respond to this error. // we leave the state as 'ERROR' which isn't great since this normally means // we're retrying. The client must be stopped before clearing the stores anyway // so the app should stop the client, clear the store and start it again. logger.warn("InvalidStoreError: store is not usable: stopping sync."); return; } if (this.opts.lazyLoadMembers && this.opts.crypto) { this.opts.crypto.enableLazyLoading(); } try { debuglog("Storing client options..."); await this.client._storeClientOptions(); debuglog("Stored client options"); } catch (err) { logger.error("Storing client options failed", err); throw err; } getFilter(); // Now get the filter and start syncing }; async function getFilter() { debuglog("Getting filter..."); let filter; if (self.opts.filter) { filter = self.opts.filter; } else { filter = buildDefaultFilter(); } let filterId; try { filterId = await client.getOrCreateFilter( getFilterName(client.credentials.userId), filter, ); } catch (err) { logger.error("Getting filter failed", err); if (self._shouldAbortSync(err)) return; // wait for saved sync to complete before doing anything else, // otherwise the sync state will end up being incorrect debuglog("Waiting for saved sync before retrying filter..."); await self.recoverFromSyncStartupError(savedSyncPromise, err); getFilter(); return; } // reset the notifications timeline to prepare it to paginate from // the current point in time. // The right solution would be to tie /sync pagination tokens into // /notifications API somehow. client.resetNotifTimelineSet(); if (self._currentSyncRequest === null) { // Send this first sync request here so we can then wait for the saved // sync data to finish processing before we process the results of this one. debuglog("Sending first sync request..."); self._currentSyncRequest = self._doSyncRequest({ filterId }, savedSyncToken); } // Now wait for the saved sync to finish... debuglog("Waiting for saved sync before starting sync processing..."); await savedSyncPromise; self._sync({ filterId }); } if (client.isGuest()) { // no push rules for guests, no access to POST filter for guests. self._sync({}); } else { // Pull the saved sync token out first, before the worker starts sending // all the sync data which could take a while. This will let us send our // first incremental sync request before we've processed our saved data. debuglog("Getting saved sync token..."); savedSyncPromise = client.store.getSavedSyncToken().then((tok) => { debuglog("Got saved sync token"); savedSyncToken = tok; debuglog("Getting saved sync..."); return client.store.getSavedSync(); }).then((savedSync) => { debuglog(`Got reply from saved sync, exists? ${!!savedSync}`); if (savedSync) { return self._syncFromCache(savedSync); } }).catch(err => { logger.error("Getting saved sync failed", err); }); // Now start the first incremental sync request: this can also // take a while so if we set it going now, we can wait for it // to finish while we process our saved sync data. getPushRules(); } }; /** * Stops the sync object from syncing. */ SyncApi.prototype.stop = function() { debuglog("SyncApi.stop"); if (global.window) { global.window.removeEventListener("online", this._onOnlineBound, false); this._onOnlineBound = undefined; } this._running = false; if (this._currentSyncRequest) { this._currentSyncRequest.abort(); } if (this._keepAliveTimer) { clearTimeout(this._keepAliveTimer); this._keepAliveTimer = null; } }; /** * Retry a backed off syncing request immediately. This should only be used when * the user explicitly attempts to retry their lost connection. * @return {boolean} True if this resulted in a request being retried. */ SyncApi.prototype.retryImmediately = function() { if (!this._connectionReturnedDefer) { return false; } this._startKeepAlives(0); return true; }; /** * Process a single set of cached sync data. * @param {Object} savedSync a saved sync that was persisted by a store. This * should have been acquired via client.store.getSavedSync(). */ SyncApi.prototype._syncFromCache = async function(savedSync) { debuglog("sync(): not doing HTTP hit, instead returning stored /sync data"); const nextSyncToken = savedSync.nextBatch; // Set sync token for future incremental syncing this.client.store.setSyncToken(nextSyncToken); // No previous sync, set old token to null const syncEventData = { oldSyncToken: null, nextSyncToken, catchingUp: false, fromCache: true, }; const data = { next_batch: nextSyncToken, rooms: savedSync.roomsData, groups: savedSync.groupsData, account_data: { events: savedSync.accountData, }, }; try { await this._processSyncResponse(syncEventData, data); } catch (e) { logger.error("Error processing cached sync", e.stack || e); } // Don't emit a prepared if we've bailed because the store is invalid: // in this case the client will not be usable until stopped & restarted // so this would be useless and misleading. if (!this._storeIsInvalid) { this._updateSyncState("PREPARED", syncEventData); } }; /** * Invoke me to do /sync calls * @param {Object} syncOptions * @param {string} syncOptions.filterId * @param {boolean} syncOptions.hasSyncedBefore */ SyncApi.prototype._sync = async function(syncOptions) { const client = this.client; if (!this._running) { debuglog("Sync no longer running: exiting."); if (this._connectionReturnedDefer) { this._connectionReturnedDefer.reject(); this._connectionReturnedDefer = null; } this._updateSyncState("STOPPED"); return; } const syncToken = client.store.getSyncToken(); let data; try { //debuglog('Starting sync since=' + syncToken); if (this._currentSyncRequest === null) { this._currentSyncRequest = this._doSyncRequest(syncOptions, syncToken); } data = await this._currentSyncRequest; } catch (e) { this._onSyncError(e, syncOptions); return; } finally { this._currentSyncRequest = null; } //debuglog('Completed sync, next_batch=' + data.next_batch); // set the sync token NOW *before* processing the events. We do this so // if something barfs on an event we can skip it rather than constantly // polling with the same token. client.store.setSyncToken(data.next_batch); // Reset after a successful sync this._failedSyncCount = 0; await client.store.setSyncData(data); const syncEventData = { oldSyncToken: syncToken, nextSyncToken: data.next_batch, catchingUp: this._catchingUp, }; if (this.opts.crypto) { // tell the crypto module we're about to process a sync // response await this.opts.crypto.onSyncWillProcess(syncEventData); } try { await this._processSyncResponse(syncEventData, data); } catch (e) { // log the exception with stack if we have it, else fall back // to the plain description logger.error("Caught /sync error", e.stack || e); // Emit the exception for client handling this.client.emit("sync.unexpectedError", e); } // update this as it may have changed syncEventData.catchingUp = this._catchingUp; // emit synced events if (!syncOptions.hasSyncedBefore) { this._updateSyncState("PREPARED", syncEventData); syncOptions.hasSyncedBefore = true; } // tell the crypto module to do its processing. It may block (to do a // /keys/changes request). if (this.opts.crypto) { await this.opts.crypto.onSyncCompleted(syncEventData); } // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates this._updateSyncState("SYNCING", syncEventData); if (client.store.wantsSave()) { // We always save the device list (if it's dirty) before saving the sync data: // this means we know the saved device list data is at least as fresh as the // stored sync data which means we don't have to worry that we may have missed // device changes. We can also skip the delay since we're not calling this very // frequently (and we don't really want to delay the sync for it). if (this.opts.crypto) { await this.opts.crypto.saveDeviceList(0); } // tell databases that everything is now in a consistent state and can be saved. client.store.save(); } // Begin next sync this._sync(syncOptions); }; SyncApi.prototype._doSyncRequest = function(syncOptions, syncToken) { const qps = this._getSyncParams(syncOptions, syncToken); return this.client._http.authedRequest( undefined, "GET", "/sync", qps, undefined, qps.timeout + BUFFER_PERIOD_MS, ); }; SyncApi.prototype._getSyncParams = function(syncOptions, syncToken) { let pollTimeout = this.opts.pollTimeout; if (this.getSyncState() !== 'SYNCING' || this._catchingUp) { // unless we are happily syncing already, we want the server to return // as quickly as possible, even if there are no events queued. This // serves two purposes: // // * When the connection dies, we want to know asap when it comes back, // so that we can hide the error from the user. (We don't want to // have to wait for an event or a timeout). // // * We want to know if the server has any to_device messages queued up // for us. We do that by calling it with a zero timeout until it // doesn't give us any more to_device messages. this._catchingUp = true; pollTimeout = 0; } let filterId = syncOptions.filterId; if (this.client.isGuest() && !filterId) { filterId = this._getGuestFilter(); } const qps = { filter: filterId, timeout: pollTimeout, }; if (this.opts.disablePresence) { qps.set_presence = "offline"; } if (syncToken) { qps.since = syncToken; } else { // use a cachebuster for initialsyncs, to make sure that // we don't get a stale sync // (https://github.com/vector-im/vector-web/issues/1354) qps._cacheBuster = Date.now(); } if (this.getSyncState() == 'ERROR' || this.getSyncState() == 'RECONNECTING') { // we think the connection is dead. If it comes back up, we won't know // about it till /sync returns. If the timeout= is high, this could // be a long time. Set it to 0 when doing retries so we don't have to wait // for an event or a timeout before emiting the SYNCING event. qps.timeout = 0; } return qps; }; SyncApi.prototype._onSyncError = function(err, syncOptions) { if (!this._running) { debuglog("Sync no longer running: exiting"); if (this._connectionReturnedDefer) { this._connectionReturnedDefer.reject(); this._connectionReturnedDefer = null; } this._updateSyncState("STOPPED"); return; } logger.error("/sync error %s", err); logger.error(err); if (this._shouldAbortSync(err)) { return; } this._failedSyncCount++; logger.log('Number of consecutive failed sync requests:', this._failedSyncCount); debuglog("Starting keep-alive"); // Note that we do *not* mark the sync connection as // lost yet: we only do this if a keepalive poke // fails, since long lived HTTP connections will // go away sometimes and we shouldn't treat this as // erroneous. We set the state to 'reconnecting' // instead, so that clients can observe this state // if they wish. this._startKeepAlives().then((connDidFail) => { // Only emit CATCHUP if we detected a connectivity error: if we didn't, // it's quite likely the sync will fail again for the same reason and we // want to stay in ERROR rather than keep flip-flopping between ERROR // and CATCHUP. if (connDidFail && this.getSyncState() === 'ERROR') { this._updateSyncState("CATCHUP", { oldSyncToken: null, nextSyncToken: null, catchingUp: true, }); } this._sync(syncOptions); }); this._currentSyncRequest = null; // Transition from RECONNECTING to ERROR after a given number of failed syncs this._updateSyncState( this._failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? "ERROR" : "RECONNECTING", { error: err }, ); }; /** * Process data returned from a sync response and propagate it * into the model objects * * @param {Object} syncEventData Object containing sync tokens associated with this sync * @param {Object} data The response from /sync */ SyncApi.prototype._processSyncResponse = async function( syncEventData, data, ) { const client = this.client; const self = this; // data looks like: // { // next_batch: $token, // presence: { events: [] }, // account_data: { events: [] }, // device_lists: { changed: ["@user:server", ... ]}, // to_device: { events: [] }, // device_one_time_keys_count: { signed_curve25519: 42 }, // rooms: { // invite: { // $roomid: { // invite_state: { events: [] } // } // }, // join: { // $roomid: { // state: { events: [] }, // timeline: { events: [], prev_batch: $token, limited: true }, // ephemeral: { events: [] }, // summary: { // m.heroes: [ $user_id ], // m.joined_member_count: $count, // m.invited_member_count: $count // }, // account_data: { events: [] }, // unread_notifications: { // highlight_count: 0, // notification_count: 0, // } // } // }, // leave: { // $roomid: { // state: { events: [] }, // timeline: { events: [], prev_batch: $token } // } // } // }, // groups: { // invite: { // $groupId: { // inviter: $inviter, // profile: { // avatar_url: $avatarUrl, // name: $groupName, // }, // }, // }, // join: {}, // leave: {}, // }, // } // TODO-arch: // - Each event we pass through needs to be emitted via 'event', can we // do this in one place? // - The isBrandNewRoom boilerplate is boilerplatey. // handle presence events (User objects) if (data.presence && 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()); const prevEventsMap = events.reduce((m, c) => { m[c.getId()] = client.store.getAccountData(c.getType()); return m; }, {}); client.store.storeAccountDataEvents(events); events.forEach( function(accountDataEvent) { // Honour push rules that come down the sync stream but also // honour push rules that were previously cached. Base rules // will be updated when we receive push rules via getPushRules // (see SyncApi.prototype.sync) before syncing over the network. if (accountDataEvent.getType() === 'm.push_rules') { const rules = accountDataEvent.getContent(); client.pushRules = PushProcessor.rewriteDefaultRules(rules); } const prevEvent = prevEventsMap[accountDataEvent.getId()]; client.emit("accountData", accountDataEvent, prevEvent); return accountDataEvent; }, ); } // handle to-device events if (data.to_device && 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 utils.promiseMapSeries(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, syncEventData.fromCache); // set summary after processing events, // because it will trigger a name calculation // which needs the room state to be up to date if (joinObj.summary) { room.setSummary(joinObj.summary); } // we deliberately don't add ephemeral events to the timeline room.addEphemeralEvents(ephemeralEvents); // we deliberately don't add accountData to the timeline room.addAccountData(accountDataEvents); room.recalculate(); if (joinObj.isBrandNewRoom) { client.store.storeRoom(room); client.emit("Room", room); } self._processEventsForNotifs(room, timelineEvents); async function processRoomEvent(e) { client.emit("event", e); if (e.isState() && e.getType() == "m.room.encryption" && self.opts.crypto) { await self.opts.crypto.onCryptoEvent(e); } if (e.isState() && e.getType() === "im.vector.user_status") { let user = client.store.getUser(e.getStateKey()); if (user) { user._unstable_updateStatusMessage(e); } else { user = createNewUser(client, e.getStateKey()); user._unstable_updateStatusMessage(e); client.store.storeUser(user); } } } await utils.promiseMapSeries(stateEvents, processRoomEvent); await utils.promiseMapSeries(timelineEvents, processRoomEvent); ephemeralEvents.forEach(function(e) { client.emit("event", e); }); accountDataEvents.forEach(function(e) { client.emit("event", e); }); room.updateMyMembership("join"); }); // Handle leaves (e.g. kicked rooms) leaveRooms.forEach(function(leaveObj) { const room = leaveObj.room; const stateEvents = self._mapSyncEventsFormat(leaveObj.state, room); const timelineEvents = self._mapSyncEventsFormat(leaveObj.timeline, room); const accountDataEvents = self._mapSyncEventsFormat(leaveObj.account_data); self._processRoomEvents(room, stateEvents, timelineEvents); room.addAccountData(accountDataEvents); room.recalculate(); if (leaveObj.isBrandNewRoom) { client.store.storeRoom(room); client.emit("Room", room); } self._processEventsForNotifs(room, timelineEvents); stateEvents.forEach(function(e) { client.emit("event", e); }); timelineEvents.forEach(function(e) { client.emit("event", e); }); accountDataEvents.forEach(function(e) { client.emit("event", e); }); room.updateMyMembership("leave"); }); // update the notification timeline, if appropriate. // we only do this for live events, as otherwise we can't order them sanely // in the timeline relative to ones paginated in by /notifications. // XXX: we could fix this by making EventTimeline support chronological // ordering... but it doesn't, right now. if (syncEventData.oldSyncToken && this._notifEvents.length) { this._notifEvents.sort(function(a, b) { return a.getTs() - b.getTs(); }); this._notifEvents.forEach(function(event) { client.getNotifTimelineSet().addLiveEvent(event); }); } // Handle device list updates if (data.device_lists) { if (this.opts.crypto) { await this.opts.crypto.handleDeviceListChanges( syncEventData, data.device_lists, ); } else { // FIXME if we *don't* have a crypto module, we still need to // invalidate the device lists. But that would require a // substantial bit of rework :/. } } // Handle one_time_keys_count if (this.opts.crypto && data.device_one_time_keys_count) { const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0; this.opts.crypto.updateOneTimeKeyCount(currentCount); } if (this.opts.crypto && data["org.matrix.msc2732.device_unused_fallback_key_types"]) { // The presence of device_unused_fallback_key_types indicates that the // server supports fallback keys. If there's no unused // signed_curve25519 fallback key we need a new one. const unusedFallbackKeys = data["org.matrix.msc2732.device_unused_fallback_key_types"]; this.opts.crypto.setNeedsNewFallback( unusedFallbackKeys instanceof Array && !unusedFallbackKeys.includes("signed_curve25519"), ); } }; /** * Starts polling the connectivity check endpoint * @param {number} delay How long to delay until the first poll. * defaults to a short, randomised interval (to prevent * tightlooping if /versions succeeds but /sync etc. fail). * @return {promise} which resolves once the connection returns */ SyncApi.prototype._startKeepAlives = function(delay) { if (delay === undefined) { delay = 2000 + Math.floor(Math.random() * 5000); } if (this._keepAliveTimer !== null) { clearTimeout(this._keepAliveTimer); } const self = this; if (delay > 0) { self._keepAliveTimer = setTimeout( self._pokeKeepAlive.bind(self), delay, ); } else { self._pokeKeepAlive(); } if (!this._connectionReturnedDefer) { this._connectionReturnedDefer = utils.defer(); } return this._connectionReturnedDefer.promise; }; /** * Make a dummy call to /_matrix/client/versions, to see if the HS is * reachable. * * On failure, schedules a call back to itself. On success, resolves * this._connectionReturnedDefer. * * @param {bool} connDidFail True if a connectivity failure has been detected. Optional. */ SyncApi.prototype._pokeKeepAlive = function(connDidFail) { if (connDidFail === undefined) connDidFail = false; const self = this; function success() { clearTimeout(self._keepAliveTimer); if (self._connectionReturnedDefer) { self._connectionReturnedDefer.resolve(connDidFail); self._connectionReturnedDefer = null; } } this.client._http.request( undefined, // callback "GET", "/_matrix/client/versions", undefined, // queryParams undefined, // data { prefix: '', localTimeoutMs: 15 * 1000, }, ).then(function() { success(); }, function(err) { if (err.httpStatus == 400 || err.httpStatus == 404) { // treat this as a success because the server probably just doesn't // support /versions: point is, we're getting a response. // We wait a short time though, just in case somehow the server // is in a mode where it 400s /versions responses and sync etc. // responses fail, this will mean we don't hammer in a loop. self._keepAliveTimer = setTimeout(success, 2000); } else { connDidFail = true; self._keepAliveTimer = setTimeout( self._pokeKeepAlive.bind(self, connDidFail), 5000 + Math.floor(Math.random() * 5000), ); // A keepalive has failed, so we emit the // error state (whether or not this is the // first failure). // Note we do this after setting the timer: // this lets the unit tests advance the mock // clock when they get the error. self._updateSyncState("ERROR", { error: err }); } }); }; /** * @param {Object} groupsSection Groups section object, eg. response.groups.invite * @param {string} sectionName Which section this is ('invite', 'join' or 'leave') */ SyncApi.prototype._processGroupSyncEntry = function(groupsSection, sectionName) { // Processes entries from 'groups' section of the sync stream for (const groupId of Object.keys(groupsSection)) { const groupInfo = groupsSection[groupId]; let group = this.client.store.getGroup(groupId); const isBrandNew = group === null; if (group === null) { group = this.createGroup(groupId); } if (groupInfo.profile) { group.setProfile( groupInfo.profile.name, groupInfo.profile.avatar_url, ); } if (groupInfo.inviter) { group.setInviter({userId: groupInfo.inviter}); } group.setMyMembership(sectionName); if (isBrandNew) { // Now we've filled in all the fields, emit the Group event this.client.emit("Group", group); } } }; /** * @param {Object} obj * @return {Object[]} */ SyncApi.prototype._mapSyncResponseToRoomArray = function(obj) { // Maps { roomid: {stuff}, roomid: {stuff} } // to // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}] const client = this.client; const self = this; return 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.then(function(info) { // slightly naughty by doctoring the invite event but this means all // the code paths remain the same between invite/join display name stuff // which is a worthy trade-off for some minor pollution. const inviteEvent = member.events.member; if (inviteEvent.getContent().membership !== "invite") { // between resolving and now they have since joined, so don't clobber return; } inviteEvent.getContent().avatar_url = info.avatar_url; inviteEvent.getContent().displayname = info.displayname; // fire listeners member.setMembershipEvent(inviteEvent, room.currentState); }, function(err) { // OH WELL. }); }); }; /** * @param {Room} room * @param {MatrixEvent[]} stateEventList A list of state events. This is the state * at the *START* of the timeline list if it is supplied. * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index * @param {boolean} fromCache whether the sync response came from cache * is earlier in time. Higher index is later. */ SyncApi.prototype._processRoomEvents = function(room, stateEventList, timelineEventList, fromCache) { // If there are no events in the timeline yet, initialise it with // the given state events const liveTimeline = room.getLiveTimeline(); const timelineWasEmpty = liveTimeline.getEvents().length == 0; if (timelineWasEmpty) { // Passing these events into initialiseState will freeze them, so we need // to compute and cache the push actions for them now, otherwise sync dies // with an attempt to assign to read only property. // XXX: This is pretty horrible and is assuming all sorts of behaviour from // these functions that it shouldn't be. We should probably either store the // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise // find some solution where MatrixEvents are immutable but allow for a cache // field. for (const ev of stateEventList) { this.client.getPushActionsForEvent(ev); } liveTimeline.initialiseState(stateEventList); } this._resolveInvites(room); // recalculate the room name at this point as adding events to the timeline // may make notifications appear which should have the right name. // XXX: This looks suspect: we'll end up recalculating the room once here // and then again after adding events (_processSyncResponse calls it after // calling us) even if no state events were added. It also means that if // one of the room events in timelineEventList is something that needs // a recalculation (like m.room.name) we won't recalculate until we've // finished adding all the events, which will cause the notification to have // the old room name rather than the new one. room.recalculate(); // If the timeline wasn't empty, we process the state events here: they're // defined as updates to the state before the start of the timeline, so this // starts to roll the state forward. // XXX: That's what we *should* do, but this can happen if we were previously // peeking in a room, in which case we obviously do *not* want to add the // state events here onto the end of the timeline. Historically, the js-sdk // has just set these new state events on the old and new state. This seems // very wrong because there could be events in the timeline that diverge the // state, in which case this is going to leave things out of sync. However, // for now I think it;s best to behave the same as the code has done previously. if (!timelineWasEmpty) { // XXX: As above, don't do this... //room.addLiveEvents(stateEventList || []); // Do this instead... room.oldState.setStateEvents(stateEventList || []); room.currentState.setStateEvents(stateEventList || []); } // execute the timeline events. This will continue to diverge the current state // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. room.addLiveEvents(timelineEventList || [], null, fromCache); }; /** * Takes a list of timelineEvents and adds and adds to _notifEvents * as appropriate. * This must be called after the room the events belong to has been stored. * * @param {Room} room * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index * is earlier in time. Higher index is later. */ SyncApi.prototype._processEventsForNotifs = function(room, timelineEventList) { // gather our notifications into this._notifEvents if (this.client.getNotifTimelineSet()) { for (let i = 0; i < timelineEventList.length; i++) { const pushActions = this.client.getPushActionsForEvent(timelineEventList[i]); if (pushActions && pushActions.notify && pushActions.tweaks && pushActions.tweaks.highlight) { this._notifEvents.push(timelineEventList[i]); } } } }; /** * @return {string} */ SyncApi.prototype._getGuestFilter = function() { 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; } matrix-js-sdk-9.11.0/src/timeline-window.js000066400000000000000000000417061403504662500205540ustar00rootroot00000000000000/* Copyright 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** @module timeline-window */ import {EventTimeline} from './models/event-timeline'; import {logger} from './logger'; /** * @private */ const DEBUG = false; /** * @private */ const debuglog = DEBUG ? logger.log.bind(logger) : function() {}; /** * the number of times we ask the server for more events before giving up * * @private */ const DEFAULT_PAGINATE_LOOP_LIMIT = 5; /** * Construct a TimelineWindow. * *

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

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

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

    This returns true if we either have more events, or if we have a * pagination token which means we can paginate in that direction. It does not * necessarily mean that there are more events available in that direction at * this time. * * @param {string} direction EventTimeline.BACKWARDS to check if we can * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards * * @return {boolean} true if we can paginate in the given direction */ TimelineWindow.prototype.canPaginate = function(direction) { const tl = this.getTimelineIndex(direction); if (!tl) { debuglog("TimelineWindow: no timeline yet"); return false; } if (direction == EventTimeline.BACKWARDS) { if (tl.index > tl.minIndex()) { return true; } } else { if (tl.index < tl.maxIndex()) { return true; } } return Boolean(tl.timeline.getNeighbouringTimeline(direction) || tl.timeline.getPaginationToken(direction)); }; /** * Attempt to extend the window * * @param {string} direction EventTimeline.BACKWARDS to extend the window * backwards (towards older events); EventTimeline.FORWARDS to go forwards. * * @param {number} size number of events to try to extend by. If fewer than this * number are immediately available, then we return immediately rather than * making an API call. * * @param {boolean} [makeRequest = true] whether we should make API calls to * fetch further events if we don't have any at all. (This has no effect if * the room already knows about additional events in the relevant direction, * even if there are fewer than 'size' of them, as we will just return those * we already know about.) * * @param {number} [requestLimit = 5] limit for the number of API requests we * should make. * * @return {Promise} Resolves to a boolean which is true if more events * were successfully retrieved. */ TimelineWindow.prototype.paginate = function(direction, size, makeRequest, requestLimit) { // Either wind back the message cap (if there are enough events in the // timeline to do so), or fire off a pagination request. if (makeRequest === undefined) { makeRequest = true; } if (requestLimit === undefined) { requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT; } const tl = this.getTimelineIndex(direction); if (!tl) { debuglog("TimelineWindow: no timeline yet"); return Promise.resolve(false); } if (tl.pendingPaginate) { return tl.pendingPaginate; } // try moving the cap if (this.extend(direction, size)) { return Promise.resolve(true); } if (!makeRequest || requestLimit === 0) { // todo: should we return something different to indicate that there // might be more events out there, but we haven't found them yet? return Promise.resolve(false); } // try making a pagination request const token = tl.timeline.getPaginationToken(direction); if (!token) { debuglog("TimelineWindow: no token"); return Promise.resolve(false); } debuglog("TimelineWindow: starting request"); const self = this; const prom = this._client.paginateEventTimeline(tl.timeline, { backwards: direction == EventTimeline.BACKWARDS, limit: size, }).finally(function() { tl.pendingPaginate = null; }).then(function(r) { debuglog("TimelineWindow: request completed with result " + r); if (!r) { // end of timeline return false; } // recurse to advance the index into the results. // // If we don't get any new events, we want to make sure we keep asking // the server for events for as long as we have a valid pagination // token. In particular, we want to know if we've actually hit the // start of the timeline, or if we just happened to know about all of // the events thanks to https://matrix.org/jira/browse/SYN-645. // // On the other hand, we necessarily want to wait forever for the // server to make its mind up about whether there are other events, // because it gives a bad user experience // (https://github.com/vector-im/vector-web/issues/1204). return self.paginate(direction, size, true, requestLimit - 1); }); tl.pendingPaginate = prom; return prom; }; /** * Remove `delta` events from the start or end of the timeline. * * @param {number} delta number of events to remove from the timeline * @param {boolean} startOfTimeline if events should be removed from the start * of the timeline. */ TimelineWindow.prototype.unpaginate = function(delta, startOfTimeline) { const tl = startOfTimeline ? this._start : this._end; // sanity-check the delta if (delta > this._eventCount || delta < 0) { throw new Error("Attemting to unpaginate " + delta + " events, but " + "only have " + this._eventCount + " in the timeline"); } while (delta > 0) { const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta); if (count <= 0) { // sadness. This shouldn't be possible. throw new Error( "Unable to unpaginate any further, but still have " + this._eventCount + " events"); } delta -= count; this._eventCount -= count; debuglog("TimelineWindow.unpaginate: dropped " + count + " (now " + this._eventCount + ")"); } }; /** * Get a list of the events currently in the window * * @return {MatrixEvent[]} the events in the window */ TimelineWindow.prototype.getEvents = function() { if (!this._start) { // not yet loaded return []; } const result = []; // iterate through each timeline between this._start and this._end // (inclusive). let timeline = this._start.timeline; while (true) { const events = timeline.getEvents(); // For the first timeline in the chain, we want to start at // this._start.index. For the last timeline in the chain, we want to // stop before this._end.index. Otherwise, we want to copy all of the // events in the timeline. // // (Note that both this._start.index and this._end.index are relative // to their respective timelines' BaseIndex). // let startIndex = 0; let endIndex = events.length; if (timeline === this._start.timeline) { startIndex = this._start.index + timeline.getBaseIndex(); } if (timeline === this._end.timeline) { endIndex = this._end.index + timeline.getBaseIndex(); } for (let i = startIndex; i < endIndex; i++) { result.push(events[i]); } // if we're not done, iterate to the next timeline. if (timeline === this._end.timeline) { break; } else { timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS); } } return result; }; /** * a thing which contains a timeline reference, and an index into it. * * @constructor * @param {EventTimeline} timeline * @param {number} index * @private */ export function TimelineIndex(timeline, index) { this.timeline = timeline; // the indexes are relative to BaseIndex, so could well be negative. this.index = index; } /** * @return {number} the minimum possible value for the index in the current * timeline */ TimelineIndex.prototype.minIndex = function() { return this.timeline.getBaseIndex() * -1; }; /** * @return {number} the maximum possible value for the index in the current * timeline (exclusive - ie, it actually returns one more than the index * of the last element). */ TimelineIndex.prototype.maxIndex = function() { return this.timeline.getEvents().length - this.timeline.getBaseIndex(); }; /** * Try move the index forward, or into the neighbouring timeline * * @param {number} delta number of events to advance by * @return {number} number of events successfully advanced by */ TimelineIndex.prototype.advance = function(delta) { if (!delta) { return 0; } // first try moving the index in the current timeline. See if there is room // to do so. let cappedDelta; if (delta < 0) { // we want to wind the index backwards. // // (this.minIndex() - this.index) is a negative number whose magnitude // is the amount of room we have to wind back the index in the current // timeline. We cap delta to this quantity. cappedDelta = Math.max(delta, this.minIndex() - this.index); if (cappedDelta < 0) { this.index += cappedDelta; return cappedDelta; } } else { // we want to wind the index forwards. // // (this.maxIndex() - this.index) is a (positive) number whose magnitude // is the amount of room we have to wind forward the index in the current // timeline. We cap delta to this quantity. cappedDelta = Math.min(delta, this.maxIndex() - this.index); if (cappedDelta > 0) { this.index += cappedDelta; return cappedDelta; } } // the index is already at the start/end of the current timeline. // // next see if there is a neighbouring timeline to switch to. const neighbour = this.timeline.getNeighbouringTimeline( delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS); if (neighbour) { this.timeline = neighbour; if (delta < 0) { this.index = this.maxIndex(); } else { this.index = this.minIndex(); } debuglog("paginate: switched to new neighbour"); // recurse, using the next timeline return this.advance(delta); } return 0; }; /** * Try move the index backwards, or into the neighbouring timeline * * @param {number} delta number of events to retreat by * @return {number} number of events successfully retreated by */ TimelineIndex.prototype.retreat = function(delta) { return this.advance(delta * -1) * -1; }; matrix-js-sdk-9.11.0/src/utils.ts000066400000000000000000000633171403504662500166150ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This is an internal module. * @module utils */ import unhomoglyph from '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 */ export function encodeParams(params: Record): string { 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'. */ export function encodeUri(pathTemplate: string, variables: Record): string { 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. */ export function map(array: T[], fn: (t: T) => S): S[] { 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. */ export function filter(array: T[], fn: (t: T, i?: number, a?: T[]) => boolean): T[] { const results: T[] = []; 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. */ export function keys(obj: object): string[] { const result = []; for (const key in obj) { if (!obj.hasOwnProperty(key)) { continue; } result.push(key); } return result; } /** * Get the values for an object. * @param {Object} obj The object to get the values for. * @return {Array<*>} The values of the object. */ export function values(obj: Record): T[] { const result = []; for (const key in obj) { if (!obj.hasOwnProperty(key)) { continue; } result.push(obj[key]); } return result; } /** * 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). */ export function forEach(array: T[], fn: (t: T, i: number) => void) { 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. */ export function findElement( array: T[], fn: (t: T, i?: number, a?: T[]) => boolean, reverse?: boolean, ) { 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. */ export function removeElement( array: T[], fn: (t: T, i?: number, a?: T[]) => boolean, reverse?: boolean, ) { 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. */ export function isFunction(value: any) { 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. */ export function isArray(value: any) { 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. */ // note using 'keys' here would shadow the 'keys' function defined above export function checkObjectHasKeys(obj: object, keys_: string[]) { 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. */ export function checkObjectHasNoAdditionalKeys(obj: object, allowedKeys: string[]): void { 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. */ export function deepCopy(obj: T): T { 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 */ export function deepCompare(x: any, y: any): boolean { // 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 */ export function extend(...restParams) { const target = restParams[0] || {}; for (let i = 1; i < restParams.length; i++) { const source = restParams[i]; if (!source) continue; 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. */ export function runPolyfills() { // Array.prototype.filter // ======================================================== // SOURCE: // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter if (!Array.prototype.filter) { // eslint-disable-next-line no-extend-native Array.prototype.filter = function(fun: Function/*, thisArg*/, ...restProps) { 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 = restProps ? restProps[0] : 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) { // eslint-disable-next-line no-extend-native Array.prototype.map = function(callback, thisArg) { let T; let 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) { let kValue; let 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) { // eslint-disable-next-line no-extend-native Array.prototype.forEach = function(callback, thisArg) { let T; let 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) { let 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. */ export function inherits(ctor: Function, superCtor: Function) { // 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 as any).super_ = superCtor; ctor.prototype = Object.create(superCtor.prototype, { constructor: { value: ctor, enumerable: false, writable: true, configurable: true, }, }); } /** * Polyfills inheritance for prototypes by allowing different kinds of * super types. Typically prototypes would use `SuperType.call(this, params)` * though this doesn't always work in some environments - this function * falls back to using `Object.assign()` to clone a constructed copy * of the super type onto `thisArg`. * @param {any} thisArg The child instance. Modified in place. * @param {any} SuperType The type to act as a super instance * @param {any} params Arguments to supply to the super type's constructor */ export function polyfillSuper(thisArg: any, SuperType: any, ...params: any[]) { try { SuperType.call(thisArg, ...params); } catch (e) { // fall back to Object.assign to just clone the thing const fakeSuper = new SuperType(...params); Object.assign(thisArg, fakeSuper); } } /** * 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 */ export function isNumber(value: any): boolean { 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 */ export function removeHiddenChars(str: string): string { if (typeof str === "string") { return unhomoglyph(str.normalize('NFD').replace(removeHiddenCharsRegex, '')); } return ""; } // Regex matching bunch of unicode control characters and otherwise misleading/invisible characters. // Includes: // various width spaces U+2000 - U+200D // LTR and RTL marks U+200E and U+200F // LTR/RTL and other directional formatting marks U+202A - U+202F // Combining characters U+0300 - U+036F // Zero width no-break space (BOM) U+FEFF // eslint-disable-next-line no-misleading-character-class const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036f\uFEFF\s]/g; export function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } export function globToRegexp(glob: string, extended: any): string { 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; } export function ensureNoTrailingSlash(url: string): string { if (url && url.endsWith("/")) { return url.substr(0, url.length - 1); } else { return url; } } // Returns a promise which resolves with a given value after the given number of ms export function sleep(ms: number, value: T): Promise { return new Promise((resolve => { setTimeout(resolve, ms, value); })); } export function isNullOrUndefined(val: any): boolean { return val === null || val === undefined; } // Returns a Deferred export function defer() { let resolve; let reject; const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); return {resolve, reject, promise}; } export async function promiseMapSeries( promises: Promise[], fn: (t: T) => void, ): Promise { for (const o of await promises) { await fn(await o); } } export function promiseTry(fn: () => T): Promise { return new Promise((resolve) => resolve(fn())); } // Creates and awaits all promises, running no more than `chunkSize` at the same time export async function chunkPromises(fns: (() => Promise)[], chunkSize: number): Promise { const results: T[] = []; for (let i = 0; i < fns.length; i += chunkSize) { results.push(...(await Promise.all(fns.slice(i, i + chunkSize).map(fn => fn())))); } return results; } // We need to be able to access the Node.js crypto library from within the // Matrix SDK without needing to `require("crypto")`, which will fail in // browsers. So `index.ts` will call `setCrypto` to store it, and when we need // it, we can call `getCrypto`. let crypto: Object; export function setCrypto(c: Object) { crypto = c; } export function getCrypto(): Object { return crypto; } matrix-js-sdk-9.11.0/src/webrtc/000077500000000000000000000000001403504662500163615ustar00rootroot00000000000000matrix-js-sdk-9.11.0/src/webrtc/call.ts000066400000000000000000002177121403504662500176560ustar00rootroot00000000000000/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 New Vector Ltd Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /** * This is an internal module. See {@link createNewMatrixCall} for the public API. * @module webrtc/call */ import {logger} from '../logger'; import {EventEmitter} from 'events'; import * as utils from '../utils'; import MatrixEvent from '../models/event'; import {EventType} from '../@types/event'; import { RoomMember } from '../models/room-member'; import { randomString } from '../randomstring'; import { MCallReplacesEvent, MCallAnswer, MCallOfferNegotiate, CallCapabilities } from './callEventTypes'; // 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); * }); */ interface CallOpts { roomId?: string, client?: any, // Fix when client is TSified forceTURN?: boolean, turnServers?: Array, } interface TurnServer { urls: Array, username?: string, password?: string, ttl?: number, } export enum CallState { Fledgling = 'fledgling', InviteSent = 'invite_sent', WaitLocalMedia = 'wait_local_media', CreateOffer = 'create_offer', CreateAnswer = 'create_answer', Connecting = 'connecting', Connected = 'connected', Ringing = 'ringing', Ended = 'ended', } export enum CallType { Voice = 'voice', Video = 'video', } export enum CallDirection { Inbound = 'inbound', Outbound = 'outbound', } export enum CallParty { Local = 'local', Remote = 'remote', } export enum CallEvent { Hangup = 'hangup', State = 'state', Error = 'error', Replaced = 'replaced', // The value of isLocalOnHold() has changed LocalHoldUnhold = 'local_hold_unhold', // The value of isRemoteOnHold() has changed RemoteHoldUnhold = 'remote_hold_unhold', // backwards compat alias for LocalHoldUnhold: remove in a major version bump HoldUnhold = 'hold_unhold', } export enum CallErrorCode { /** The user chose to end the call */ UserHangup = 'user_hangup', /** An error code when the local client failed to create an offer. */ LocalOfferFailed = '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. */ NoUserMedia = 'no_user_media', /** * Error code used when a call event failed to send * because unknown devices were present in the room */ UnknownDevices = 'unknown_devices', /** * Error code usewd when we fail to send the invite * for some reason other than there being unknown devices */ SendInvite = 'send_invite', /** * An answer could not be created */ CreateAnswer = 'create_answer', /** * Error code usewd when we fail to send the answer * for some reason other than there being unknown devices */ SendAnswer = 'send_answer', /** * The session description from the other side could not be set */ SetRemoteDescription = 'set_remote_description', /** * The session description from this side could not be set */ SetLocalDescription = 'set_local_description', /** * A different device answered the call */ AnsweredElsewhere = 'answered_elsewhere', /** * No media connection could be established to the other party */ IceFailed = 'ice_failed', /** * The invite timed out whilst waiting for an answer */ InviteTimeout = 'invite_timeout', /** * The call was replaced by another call */ Replaced = 'replaced', /** * Signalling for the call could not be sent (other than the initial invite) */ SignallingFailed = 'signalling_timeout', } enum ConstraintsType { Audio = "audio", Video = "video", } /** * The version field that we set in m.call.* events */ const VOIP_PROTO_VERSION = 1; /** The fallback ICE server to use for STUN or TURN protocols. */ const FALLBACK_ICE_SERVER = 'stun:turn.matrix.org'; /** The length of time a call can be ringing for. */ const CALL_TIMEOUT_MS = 60000; /** Retrieves sources from desktopCapturer */ export function getDesktopCapturerSources(): Promise> { const options: GetSourcesOptions = { thumbnailSize: { height: 176, width: 312, }, types: [ "screen", "window", ], }; return window.electron.getDesktopCapturerSources(options); } export class CallError extends Error { code : string; constructor(code : CallErrorCode, msg: string, err: Error) { // Stil ldon't think there's any way to have proper nested errors super(msg + ": " + err); this.code = code; } } function genCallID(): string { return Date.now().toString() + randomString(16); } /** * 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. */ export class MatrixCall extends EventEmitter { roomId: string; type: CallType; callId: string; state: CallState; hangupParty: CallParty; hangupReason: string; direction: CallDirection; ourPartyId: string; private client: any; // Fix when client is TSified private forceTURN: boolean; private turnServers: Array; private candidateSendQueue: Array; private candidateSendTries: number; private sentEndOfCandidates: boolean; private peerConn: RTCPeerConnection; private localVideoElement: HTMLVideoElement; private remoteVideoElement: HTMLVideoElement; private remoteAudioElement: HTMLAudioElement; private screenSharingStream: MediaStream; private remoteStream: MediaStream; private localAVStream: MediaStream; private inviteOrAnswerSent: boolean; private waitForLocalAVStream: boolean; // XXX: I don't know why this is called 'config'. private config: MediaStreamConstraints; private successor: MatrixCall; private opponentMember: RoomMember; private opponentVersion: number; // The party ID of the other side: undefined if we haven't chosen a partner // yet, null if we have but they didn't send a party ID. private opponentPartyId: string; private opponentCaps: CallCapabilities; private inviteTimeout: NodeJS.Timeout; // in the browser it's 'number' // The logic of when & if a call is on hold is nontrivial and explained in is*OnHold // This flag represents whether we want the other party to be on hold private remoteOnHold; // and this one we set when we're transitioning out of the hold state because we // can't tell the difference between that and the other party holding us private unholdingRemote; private micMuted; private vidMuted; // the stats for the call at the point it ended. We can't get these after we // tear the call down, so we just grab a snapshot before we stop the call. // The typescript definitions have this type as 'any' :( private callStatsAtEnd: any[]; // Perfect negotiation state: https://www.w3.org/TR/webrtc/#perfect-negotiation-example private makingOffer: boolean; private ignoreOffer: boolean; // If candidates arrive before we've picked an opponent (which, in particular, // will happen if the opponent sends candidates eagerly before the user answers // the call) we buffer them up here so we can then add the ones from the party we pick private remoteCandidateBuffer = new Map(); constructor(opts: CallOpts) { super(); this.roomId = opts.roomId; this.client = opts.client; this.type = null; this.forceTURN = opts.forceTURN; this.ourPartyId = this.client.deviceId; // Array of Objects with urls, username, credential keys this.turnServers = opts.turnServers || []; if (this.turnServers.length === 0 && this.client.isFallbackICEServerAllowed()) { this.turnServers.push({ urls: [FALLBACK_ICE_SERVER], }); } for (const server of this.turnServers) { utils.checkObjectHasKeys(server, ["urls"]); } this.callId = genCallID(); this.state = CallState.Fledgling; // 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; this.sentEndOfCandidates = false; this.inviteOrAnswerSent = false; this.makingOffer = false; this.remoteOnHold = false; this.unholdingRemote = false; this.micMuted = false; this.vidMuted = false; } /** * Place a voice call to this room. * @throws If you have not specified a listener for 'error' events. */ async placeVoiceCall() { logger.debug("placeVoiceCall"); this.checkForErrorListener(); const constraints = getUserMediaContraints(ConstraintsType.Audio); this.type = CallType.Voice; await this.placeCallWithConstraints(constraints); } /** * 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. */ async placeVideoCall(remoteVideoElement: HTMLVideoElement, localVideoElement: HTMLVideoElement) { logger.debug("placeVideoCall"); this.checkForErrorListener(); this.localVideoElement = localVideoElement; this.remoteVideoElement = remoteVideoElement; const constraints = getUserMediaContraints(ConstraintsType.Video); this.type = CallType.Video; await this.placeCallWithConstraints(constraints); } /** * 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. */ async placeScreenSharingCall( remoteVideoElement: HTMLVideoElement, localVideoElement: HTMLVideoElement, selectDesktopCapturerSource?: () => Promise, ) { logger.debug("placeScreenSharingCall"); this.checkForErrorListener(); this.localVideoElement = localVideoElement; this.remoteVideoElement = remoteVideoElement; try { const screenshareConstraints = await getScreenshareContraints(selectDesktopCapturerSource); if (!screenshareConstraints) { this.terminate(CallParty.Local, CallErrorCode.NoUserMedia, false); return; } if (window.electron?.getDesktopCapturerSources) { // We are using Electron logger.debug("Getting screen stream using getUserMedia()..."); this.screenSharingStream = await navigator.mediaDevices.getUserMedia(screenshareConstraints); } else { // We are not using Electron logger.debug("Getting screen stream using getDisplayMedia()..."); this.screenSharingStream = await navigator.mediaDevices.getDisplayMedia(screenshareConstraints); } logger.debug("Got screen stream, requesting audio stream..."); const audioConstraints = getUserMediaContraints(ConstraintsType.Audio); this.placeCallWithConstraints(audioConstraints); } catch (err) { this.emit(CallEvent.Error, new CallError( CallErrorCode.NoUserMedia, "Failed to get screen-sharing stream: ", err, ), ); this.terminate(CallParty.Local, CallErrorCode.NoUserMedia, false); } this.type = CallType.Video; } public getOpponentMember() { return this.opponentMember; } public opponentCanBeTransferred() { return Boolean(this.opponentCaps && this.opponentCaps["m.call.transferee"]); } /** * Retrieve the local <video> DOM element. * @return {Element} The dom element */ public getLocalVideoElement(): HTMLVideoElement { return this.localVideoElement; } /** * Retrieve the remote <video> DOM element * used for playing back video capable streams. * @return {Element} The dom element */ public getRemoteVideoElement(): HTMLVideoElement { return this.remoteVideoElement; } /** * Retrieve the remote <audio> DOM element * used for playing back audio only streams. * @return {Element} The dom element */ public getRemoteAudioElement(): HTMLAudioElement { return this.remoteAudioElement; } /** * Set the local <video> DOM element. If this call is active, * video will be rendered to it immediately. * @param {Element} element The <video> DOM element. */ public async setLocalVideoElement(element : HTMLVideoElement) { this.localVideoElement = element; if (element && this.localAVStream && this.type === CallType.Video) { element.autoplay = true; element.srcObject = this.localAVStream; element.muted = true; try { await element.play(); } catch (e) { logger.info("Failed to play local video element", e); } } } /** * Set the remote <video> DOM element. If this call is active, * the first received video-capable stream will be rendered to it immediately. * @param {Element} element The <video> DOM element. */ public setRemoteVideoElement(element : HTMLVideoElement) { if (element === this.remoteVideoElement) return; element.autoplay = true; // if we already have an audio element set, use that instead and mute the audio // on this video element. if (this.remoteAudioElement) element.muted = true; this.remoteVideoElement = element; if (this.remoteStream) { this.playRemoteVideo(); } } /** * Set the remote <audio> DOM element. If this call is active, * the first received audio-only stream will be rendered to it immediately. * The audio will *not* be rendered from the remoteVideoElement. * @param {Element} element The <video> DOM element. */ public async setRemoteAudioElement(element: HTMLAudioElement) { if (element === this.remoteAudioElement) return; this.remoteAudioElement = element; if (this.remoteStream) this.playRemoteAudio(); } // The typescript definitions have this type as 'any' :( public async getCurrentCallStats(): Promise { if (this.callHasEnded()) { return this.callStatsAtEnd; } return this.collectCallStats(); } private async collectCallStats(): Promise { // This happens when the call fails before it starts. // For example when we fail to get capture sources if (!this.peerConn) return; const statsReport = await this.peerConn.getStats(); const stats = []; for (const item of statsReport) { stats.push(item[1]); } return stats; } /** * Configure this call from an invite event. Used by MatrixClient. * @param {MatrixEvent} event The m.call.invite event */ async initWithInvite(event: MatrixEvent) { const invite = event.getContent(); this.direction = CallDirection.Inbound; // make sure we have valid turn creds. Unless something's gone wrong, it should // poll and keep the credentials valid so this should be instant. const haveTurnCreds = await this.client._checkTurnServers(); if (!haveTurnCreds) { logger.warn("Failed to get TURN credentials! Proceeding with call anyway..."); } this.peerConn = this.createPeerConnection(); // we must set the party ID before await-ing on anything: the call event // handler will start giving us more call events (eg. candidates) so if // we haven't set the party ID, we'll ignore them. this.chooseOpponent(event); try { await this.peerConn.setRemoteDescription(invite.offer); await this.addBufferedIceCandidates(); } catch (e) { logger.debug("Failed to set remote description", e); this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false); return; } // According to previous comments in this file, firefox at some point did not // add streams until media started ariving on them. Testing latest firefox // (81 at time of writing), this is no longer a problem, so let's do it the correct way. if (!this.remoteStream || this.remoteStream.getTracks().length === 0) { logger.error("No remote stream or no tracks after setting remote description!"); this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false); return; } this.type = this.remoteStream.getTracks().some(t => t.kind === 'video') ? CallType.Video : CallType.Voice; this.setState(CallState.Ringing); if (event.getLocalAge()) { setTimeout(() => { if (this.state == CallState.Ringing) { logger.debug("Call invite has expired. Hanging up."); this.hangupParty = CallParty.Remote; // effectively this.setState(CallState.Ended); this.stopAllMedia(); if (this.peerConn.signalingState != 'closed') { this.peerConn.close(); } this.emit(CallEvent.Hangup); } }, invite.lifetime - event.getLocalAge()); } } /** * Configure this call from a hangup or reject event. Used by MatrixClient. * @param {MatrixEvent} event The m.call.hangup event */ initWithHangup(event: MatrixEvent) { // perverse as it may seem, sometimes we want to instantiate a call with a // hangup message (because when getting the state of the room on load, events // come in reverse order and we want to remember that a call has been hung up) this.setState(CallState.Ended); } /** * Answer a call. */ async answer() { if (this.inviteOrAnswerSent) { return; } logger.debug(`Answering call ${this.callId} of type ${this.type}`); if (!this.localAVStream && !this.waitForLocalAVStream) { const constraints = getUserMediaContraints( this.type == CallType.Video ? ConstraintsType.Video: ConstraintsType.Audio, ); logger.log("Getting user media with constraints", constraints); this.setState(CallState.WaitLocalMedia); this.waitForLocalAVStream = true; try { const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); this.waitForLocalAVStream = false; this.gotUserMediaForAnswer(mediaStream); } catch (e) { this.getUserMediaFailed(e); return } } else if (this.localAVStream) { this.gotUserMediaForAnswer(this.localAVStream); } else if (this.waitForLocalAVStream) { this.setState(CallState.WaitLocalMedia); } } /** * Replace this call with a new call, e.g. for glare resolution. Used by * MatrixClient. * @param {MatrixCall} newCall The new call. */ replacedBy(newCall: MatrixCall) { logger.debug(this.callId + " being replaced by " + newCall.callId); if (this.state === CallState.WaitLocalMedia) { logger.debug("Telling new call to wait for local media"); newCall.waitForLocalAVStream = true; } else if (this.state === CallState.CreateOffer) { logger.debug("Handing local stream to new call"); newCall.gotUserMediaForAnswer(this.localAVStream); delete(this.localAVStream); } else if (this.state === CallState.InviteSent) { logger.debug("Handing local stream to new call"); newCall.gotUserMediaForAnswer(this.localAVStream); delete(this.localAVStream); } newCall.localVideoElement = this.localVideoElement; newCall.remoteVideoElement = this.remoteVideoElement; newCall.remoteAudioElement = this.remoteAudioElement; this.successor = newCall; this.emit(CallEvent.Replaced, newCall); this.hangup(CallErrorCode.Replaced, true); } /** * Hangup a call. * @param {string} reason The reason why the call is being hung up. * @param {boolean} suppressEvent True to suppress emitting an event. */ hangup(reason: CallErrorCode, suppressEvent: boolean) { if (this.callHasEnded()) return; logger.debug("Ending call " + this.callId); this.terminate(CallParty.Local, reason, !suppressEvent); // We don't want to send hangup here if we didn't even get to sending an invite if (this.state === CallState.WaitLocalMedia) return; const content = {}; // Continue to send no reason for user hangups temporarily, until // clients understand the user_hangup reason (voip v1) if (reason !== CallErrorCode.UserHangup) content['reason'] = reason; this.sendVoipEvent(EventType.CallHangup, {}); } /** * Reject a call * This used to be done by calling hangup, but is a separate method and protocol * event as of MSC2746. */ reject() { if (this.state !== CallState.Ringing) { throw Error("Call must be in 'ringing' state to reject!"); } if (this.opponentVersion < 1) { logger.info( `Opponent version is less than 1 (${this.opponentVersion}): sending hangup instead of reject`, ); this.hangup(CallErrorCode.UserHangup, true); return; } logger.debug("Rejecting call: " + this.callId); this.terminate(CallParty.Local, CallErrorCode.UserHangup, true); this.sendVoipEvent(EventType.CallReject, {}); } /** * Set whether our outbound video should be muted or not. * @param {boolean} muted True to mute the outbound video. */ setLocalVideoMuted(muted: boolean) { this.vidMuted = muted; this.updateMuteStatus(); } /** * Check if local video is muted. * * If there are multiple video tracks, all of the tracks need to be muted * for this to return true. This means if there are no video tracks, this will * return true. * @return {Boolean} True if the local preview video is muted, else false * (including if the call is not set up yet). */ isLocalVideoMuted(): boolean { return this.vidMuted; } /** * Set whether the microphone should be muted or not. * @param {boolean} muted True to mute the mic. */ setMicrophoneMuted(muted: boolean) { this.micMuted = muted; this.updateMuteStatus(); } /** * Check if the microphone is muted. * * If there are multiple audio tracks, all of the tracks need to be muted * for this to return true. This means if there are no audio tracks, this will * return true. * @return {Boolean} True if the mic is muted, else false (including if the call * is not set up yet). */ isMicrophoneMuted(): boolean { return this.micMuted; } /** * @returns true if we have put the party on the other side of the call on hold * (that is, we are signalling to them that we are not listening) */ isRemoteOnHold(): boolean { return this.remoteOnHold; } setRemoteOnHold(onHold: boolean) { if (this.isRemoteOnHold() === onHold) return; this.remoteOnHold = onHold; if (!onHold) this.unholdingRemote = true; for (const tranceiver of this.peerConn.getTransceivers()) { // We set 'inactive' rather than 'sendonly' because we're not planning on // playing music etc. to the other side. tranceiver.direction = onHold ? 'inactive' : 'sendrecv'; } this.updateMuteStatus(); if (!onHold) { this.playRemoteAudio(); } this.emit(CallEvent.RemoteHoldUnhold, this.remoteOnHold); } /** * Indicates whether we are 'on hold' to the remote party (ie. if true, * they cannot hear us). Note that this will return true when we put the * remote on hold too due to the way hold is implemented (since we don't * wish to play hold music when we put a call on hold, we use 'inactive' * rather than 'sendonly') * @returns true if the other party has put us on hold */ isLocalOnHold(): boolean { if (this.state !== CallState.Connected) return false; if (this.unholdingRemote) return false; let callOnHold = true; // We consider a call to be on hold only if *all* the tracks are on hold // (is this the right thing to do?) for (const tranceiver of this.peerConn.getTransceivers()) { const trackOnHold = ['inactive', 'recvonly'].includes(tranceiver.currentDirection); if (!trackOnHold) callOnHold = false; } return callOnHold; } /** * Sends a DTMF digit to the other party * @param digit The digit (nb. string - '#' and '*' are dtmf too) */ sendDtmfDigit(digit: string) { for (const sender of this.peerConn.getSenders()) { if (sender.track.kind === 'audio' && sender.dtmf) { sender.dtmf.insertDTMF(digit); return; } } throw new Error("Unable to find a track to send DTMF on"); } private updateMuteStatus() { if (!this.localAVStream) { return; } const micShouldBeMuted = this.micMuted || this.remoteOnHold; setTracksEnabled(this.localAVStream.getAudioTracks(), !micShouldBeMuted); const vidShouldBeMuted = this.vidMuted || this.remoteOnHold; setTracksEnabled(this.localAVStream.getVideoTracks(), !vidShouldBeMuted); if (this.remoteOnHold) { if (this.remoteAudioElement && this.remoteAudioElement.srcObject === this.remoteStream) { this.remoteAudioElement.muted = true; } else if (this.remoteVideoElement && this.remoteVideoElement.srcObject === this.remoteStream) { this.remoteVideoElement.muted = true; } } else { this.playRemoteAudio(); } } /** * Internal * @param {Object} stream */ private gotUserMediaForInvite = async (stream: MediaStream) => { if (this.successor) { this.successor.gotUserMediaForAnswer(stream); return; } if (this.callHasEnded()) { this.stopAllMedia(); return; } this.localAVStream = stream; logger.info("Got local AV stream with id " + this.localAVStream.id); this.setState(CallState.CreateOffer); logger.debug("gotUserMediaForInvite -> " + this.type); const videoEl = this.getLocalVideoElement(); if (videoEl && this.type === CallType.Video) { videoEl.autoplay = true; if (this.screenSharingStream) { logger.debug( "Setting screen sharing stream to the local video element", ); videoEl.srcObject = this.screenSharingStream; } else { videoEl.srcObject = stream; } videoEl.muted = true; try { await videoEl.play(); } catch (e) { logger.info("Failed to play local video element", e); } } // why do we enable audio (and only audio) tracks here? -- matthew setTracksEnabled(stream.getAudioTracks(), true); for (const audioTrack of stream.getAudioTracks()) { logger.info("Adding audio track with id " + audioTrack.id); this.peerConn.addTrack(audioTrack, stream); } for (const videoTrack of (this.screenSharingStream || stream).getVideoTracks()) { logger.info("Adding video track with id " + videoTrack.id); this.peerConn.addTrack(videoTrack, stream); } // Now we wait for the negotiationneeded event }; private async sendAnswer() { const answerContent = { answer: { sdp: this.peerConn.localDescription.sdp, // type is now deprecated as of Matrix VoIP v1, but // required to still be sent for backwards compat type: this.peerConn.localDescription.type, }, } as MCallAnswer; if (this.client._supportsCallTransfer) { answerContent.capabilities = { 'm.call.transferee': true, } } // We have just taken the local description from the peerconnection which will // contain all the local candidates added so far, so we can discard any candidates // we had queued up because they'll be in the answer. logger.info(`Discarding ${this.candidateSendQueue.length} candidates that will be sent in answer`); this.candidateSendQueue = []; try { await this.sendVoipEvent(EventType.CallAnswer, answerContent); // If this isn't the first time we've tried to send the answer, // we may have candidates queued up, so send them now. this.inviteOrAnswerSent = true; } catch (error) { // We've failed to answer: back to the ringing state this.setState(CallState.Ringing); this.client.cancelPendingEvent(error.event); let code = CallErrorCode.SendAnswer; let message = "Failed to send answer"; if (error.name == 'UnknownDeviceError') { code = CallErrorCode.UnknownDevices; message = "Unknown devices present in the room"; } this.emit(CallEvent.Error, new CallError(code, message, error)); throw error; } // error handler re-throws so this won't happen on error, but // we don't want the same error handling on the candidate queue this.sendCandidateQueue(); } private gotUserMediaForAnswer = async (stream: MediaStream) => { if (this.callHasEnded()) { return; } const localVidEl = this.getLocalVideoElement(); if (localVidEl && this.type === CallType.Video) { localVidEl.autoplay = true; localVidEl.srcObject = stream; localVidEl.muted = true; try { await localVidEl.play(); } catch (e) { logger.info("Failed to play local video element", e); } } this.localAVStream = stream; logger.info("Got local AV stream with id " + this.localAVStream.id); setTracksEnabled(stream.getAudioTracks(), true); for (const track of stream.getTracks()) { this.peerConn.addTrack(track, stream); } this.setState(CallState.CreateAnswer); let myAnswer; try { myAnswer = await this.peerConn.createAnswer(); } catch (err) { logger.debug("Failed to create answer: ", err); this.terminate(CallParty.Local, CallErrorCode.CreateAnswer, true); return; } try { await this.peerConn.setLocalDescription(myAnswer); this.setState(CallState.Connecting); // Allow a short time for initial candidates to be gathered await new Promise(resolve => { setTimeout(resolve, 200); }); this.sendAnswer(); } catch (err) { logger.debug("Error setting local description!", err); this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true); return; } }; /** * Internal * @param {Object} event */ private gotLocalIceCandidate = (event: RTCPeerConnectionIceEvent) => { if (event.candidate) { logger.debug( "Call " + this.callId + " got local ICE " + event.candidate.sdpMid + " candidate: " + event.candidate.candidate, ); if (this.callHasEnded()) return; // As with the offer, note we need to make a copy of this object, not // pass the original: that broke in Chrome ~m43. if (event.candidate.candidate !== '' || !this.sentEndOfCandidates) { this.queueCandidate(event.candidate); if (event.candidate.candidate === '') this.sentEndOfCandidates = true; } } }; private onIceGatheringStateChange = (event: Event) => { logger.debug("ice gathering state changed to " + this.peerConn.iceGatheringState); if (this.peerConn.iceGatheringState === 'complete' && !this.sentEndOfCandidates) { // If we didn't get an empty-string candidate to signal the end of candidates, // create one ourselves now gathering has finished. // We cast because the interface lists all the properties as required but we // only want to send 'candidate' // XXX: We probably want to send either sdpMid or sdpMLineIndex, as it's not strictly // correct to have a candidate that lacks both of these. We'd have to figure out what // previous candidates had been sent with and copy them. const c = { candidate: '', } as RTCIceCandidate; this.queueCandidate(c); this.sentEndOfCandidates = true; } }; async onRemoteIceCandidatesReceived(ev: MatrixEvent) { if (this.callHasEnded()) { //debuglog("Ignoring remote ICE candidate because call has ended"); return; } const cands = ev.getContent().candidates; if (!cands) { logger.info("Ignoring candidates event with no candidates!"); return; } const fromPartyId = ev.getContent().version === 0 ? null : ev.getContent().party_id || null; if (this.opponentPartyId === undefined) { // we haven't picked an opponent yet so save the candidates logger.info(`Bufferring ${cands.length} candidates until we pick an opponent`); const bufferedCands = this.remoteCandidateBuffer.get(fromPartyId) || []; bufferedCands.push(...cands); this.remoteCandidateBuffer.set(fromPartyId, bufferedCands); return; } if (!this.partyIdMatches(ev.getContent())) { logger.info( `Ignoring candidates from party ID ${ev.getContent().party_id}: ` + `we have chosen party ID ${this.opponentPartyId}`, ); return; } await this.addIceCandidates(cands); } /** * Used by MatrixClient. * @param {Object} msg */ async onAnswerReceived(event: MatrixEvent) { logger.debug(`Got answer for call ID ${this.callId} from party ID ${event.getContent().party_id}`); if (this.callHasEnded()) { logger.debug(`Ignoring answer because call ID ${this.callId} has ended`); return; } if (this.opponentPartyId !== undefined) { logger.info( `Ignoring answer from party ID ${event.getContent().party_id}: ` + `we already have an answer/reject from ${this.opponentPartyId}`, ); return; } this.chooseOpponent(event); await this.addBufferedIceCandidates(); this.setState(CallState.Connecting); try { await this.peerConn.setRemoteDescription(event.getContent().answer); } catch (e) { logger.debug("Failed to set remote description", e); this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false); return; } // If the answer we selected has a party_id, send a select_answer event // We do this after setting the remote description since otherwise we'd block // call setup on it if (this.opponentPartyId !== null) { try { await this.sendVoipEvent(EventType.CallSelectAnswer, { selected_party_id: this.opponentPartyId, }); } catch (err) { // This isn't fatal, and will just mean that if another party has raced to answer // the call, they won't know they got rejected, so we carry on & don't retry. logger.warn("Failed to send select_answer event", err); } } } async onSelectAnswerReceived(event: MatrixEvent) { if (this.direction !== CallDirection.Inbound) { logger.warn("Got select_answer for an outbound call: ignoring"); return; } const selectedPartyId = event.getContent().selected_party_id; if (selectedPartyId === undefined || selectedPartyId === null) { logger.warn("Got nonsensical select_answer with null/undefined selected_party_id: ignoring"); return; } if (selectedPartyId !== this.ourPartyId) { logger.info(`Got select_answer for party ID ${selectedPartyId}: we are party ID ${this.ourPartyId}.`); // The other party has picked somebody else's answer this.terminate(CallParty.Remote, CallErrorCode.AnsweredElsewhere, true); } } async onNegotiateReceived(event: MatrixEvent) { const description = event.getContent().description; if (!description || !description.sdp || !description.type) { logger.info("Ignoring invalid m.call.negotiate event"); return; } // Politeness always follows the direction of the call: in a glare situation, // we pick either the inbound or outbound call, so one side will always be // inbound and one outbound const polite = this.direction === CallDirection.Inbound; // Here we follow the perfect negotiation logic from // https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation const offerCollision = ( (description.type === 'offer') && (this.makingOffer || this.peerConn.signalingState != 'stable') ); this.ignoreOffer = !polite && offerCollision; if (this.ignoreOffer) { logger.info("Ignoring colliding negotiate event because we're impolite"); return; } const prevLocalOnHold = this.isLocalOnHold(); if (description.type === 'answer') { // whenever we get an answer back, clear the flag we set whilst trying to un-hold // the other party: the state of the channels now reflects reality this.unholdingRemote = false; } try { await this.peerConn.setRemoteDescription(description); if (description.type === 'offer') { // First we sent the direction of the tranciever to what we'd like it to be, // irresepective of whether the other side has us on hold - so just whether we // want the call to be on hold or not. This is necessary because in a few lines, // we'll adjust the direction and unless we do this too, we'll never come off hold. for (const tranceiver of this.peerConn.getTransceivers()) { tranceiver.direction = this.isRemoteOnHold() ? 'inactive' : 'sendrecv'; } const localDescription = await this.peerConn.createAnswer(); await this.peerConn.setLocalDescription(localDescription); // Now we've got our answer, set the direction to the outcome of the negotiation. // We need to do this otherwise Firefox will notice that the direction is not the // currentDirection and try to negotiate itself off hold again. for (const tranceiver of this.peerConn.getTransceivers()) { tranceiver.direction = tranceiver.currentDirection; } this.sendVoipEvent(EventType.CallNegotiate, { description: this.peerConn.localDescription, }); } } catch (err) { logger.warn("Failed to complete negotiation", err); } const newLocalOnHold = this.isLocalOnHold(); if (prevLocalOnHold !== newLocalOnHold) { this.emit(CallEvent.LocalHoldUnhold, newLocalOnHold); // also this one for backwards compat this.emit(CallEvent.HoldUnhold, newLocalOnHold); } } private callHasEnded() : boolean { // This exists as workaround to typescript trying to be clever and erroring // when putting if (this.state === CallState.Ended) return; twice in the same // function, even though that function is async. return this.state === CallState.Ended; } private gotLocalOffer = async (description: RTCSessionDescriptionInit) => { logger.debug("Created offer: ", description); if (this.callHasEnded()) { logger.debug("Ignoring newly created offer on call ID " + this.callId + " because the call has ended"); return; } try { await this.peerConn.setLocalDescription(description); } catch (err) { logger.debug("Error setting local description!", err); this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true); return } if (this.peerConn.iceGatheringState === 'gathering') { // Allow a short time for initial candidates to be gathered await new Promise(resolve => { setTimeout(resolve, 200); }); } if (this.callHasEnded()) return; const eventType = this.state === CallState.CreateOffer ? EventType.CallInvite : EventType.CallNegotiate; const content = { lifetime: CALL_TIMEOUT_MS, } as MCallOfferNegotiate; // clunky because TypeScript can't folow the types through if we use an expression as the key if (this.state === CallState.CreateOffer) { content.offer = this.peerConn.localDescription; } else { content.description = this.peerConn.localDescription; } if (this.client._supportsCallTransfer) { content.capabilities = { 'm.call.transferee': true, } } // Get rid of any candidates waiting to be sent: they'll be included in the local // description we just got and will send in the offer. logger.info(`Discarding ${this.candidateSendQueue.length} candidates that will be sent in offer`); this.candidateSendQueue = []; try { await this.sendVoipEvent(eventType, content); } catch (error) { logger.error("Failed to send invite", error); if (error.event) this.client.cancelPendingEvent(error.event); let code = CallErrorCode.SignallingFailed; let message = "Signalling failed"; if (this.state === CallState.CreateOffer) { code = CallErrorCode.SendInvite; message = "Failed to send invite"; } if (error.name == 'UnknownDeviceError') { code = CallErrorCode.UnknownDevices; message = "Unknown devices present in the room"; } this.emit(CallEvent.Error, new CallError(code, message, error)); this.terminate(CallParty.Local, code, false); // no need to carry on & send the candidate queue, but we also // don't want to rethrow the error return; } this.sendCandidateQueue(); if (this.state === CallState.CreateOffer) { this.inviteOrAnswerSent = true; this.setState(CallState.InviteSent); this.inviteTimeout = setTimeout(() => { this.inviteTimeout = null; if (this.state === CallState.InviteSent) { this.hangup(CallErrorCode.InviteTimeout, false); } }, CALL_TIMEOUT_MS); } }; private getLocalOfferFailed = (err: Error) => { logger.error("Failed to get local offer", err); this.emit( CallEvent.Error, new CallError( CallErrorCode.LocalOfferFailed, "Failed to get local offer!", err, ), ); this.terminate(CallParty.Local, CallErrorCode.LocalOfferFailed, false); }; private getUserMediaFailed = (err: Error) => { if (this.successor) { this.successor.getUserMediaFailed(err); return; } logger.warn("Failed to get user media - ending call", err); this.emit( CallEvent.Error, new CallError( CallErrorCode.NoUserMedia, "Couldn't start capturing media! Is your microphone set up and " + "does this app have permission?", err, ), ); this.terminate(CallParty.Local, CallErrorCode.NoUserMedia, false); }; onIceConnectionStateChanged = () => { if (this.callHasEnded()) { return; // because ICE can still complete as we're ending the call } logger.debug( "Call ID " + this.callId + ": ICE connection state changed to: " + this.peerConn.iceConnectionState, ); // ideally we'd consider the call to be connected when we get media but // chrome doesn't implement any of the 'onstarted' events yet if (this.peerConn.iceConnectionState == 'connected') { this.setState(CallState.Connected); } else if (this.peerConn.iceConnectionState == 'failed') { this.hangup(CallErrorCode.IceFailed, false); } }; private onSignallingStateChanged = () => { logger.debug( "call " + this.callId + ": Signalling state changed to: " + this.peerConn.signalingState, ); }; private onTrack = (ev: RTCTrackEvent) => { if (ev.streams.length === 0) { logger.warn(`Streamless ${ev.track.kind} found: ignoring.`); return; } // If we already have a stream, check this track is from the same one if (this.remoteStream && ev.streams[0].id !== this.remoteStream.id) { logger.warn( `Ignoring new stream ID ${ev.streams[0].id}: we already have stream ID ${this.remoteStream.id}`, ); return; } if (!this.remoteStream) { logger.info("Got remote stream with id " + ev.streams[0].id); } // Note that we check by ID above and always set the remote stream: Chrome appears // to make new stream objects when tranciever directionality is changed and the 'active' // status of streams change this.remoteStream = ev.streams[0]; logger.debug(`Track id ${ev.track.id} of kind ${ev.track.kind} added`); if (ev.track.kind === 'video') { if (this.remoteVideoElement) { this.playRemoteVideo(); } } else { if (this.remoteAudioElement) this.playRemoteAudio(); } }; onNegotiationNeeded = async () => { logger.info("Negotation is needed!"); if (this.state !== CallState.CreateOffer && this.opponentVersion === 0) { logger.info("Opponent does not support renegotiation: ignoring negotiationneeded event"); return; } this.makingOffer = true; try { const myOffer = await this.peerConn.createOffer(); await this.gotLocalOffer(myOffer); } catch (e) { this.getLocalOfferFailed(e); return; } finally { this.makingOffer = false; } }; async playRemoteAudio() { if (this.remoteVideoElement) this.remoteVideoElement.muted = true; this.remoteAudioElement.muted = false; this.remoteAudioElement.srcObject = this.remoteStream; // if audioOutput is non-default: try { if (audioOutput) { // This seems quite unreliable in Chrome, although I haven't yet managed to make a jsfiddle where // it fails. // It seems reliable if you set the sink ID after setting the srcObject and then set the sink ID // back to the default after the call is over logger.info("Setting audio sink to " + audioOutput + ", was " + this.remoteAudioElement.sinkId); await this.remoteAudioElement.setSinkId(audioOutput); } } catch (e) { logger.warn("Couldn't set requested audio output device: using default", e); } try { await this.remoteAudioElement.play(); } catch (e) { logger.error("Failed to play remote audio element", e); } } private async playRemoteVideo() { // A note on calling methods on media elements: // We used to have queues per media element to serialise all calls on those elements. // The reason given for this was that load() and play() were racing. However, we now // never call load() explicitly so this seems unnecessary. However, serialising every // operation was causing bugs where video would not resume because some play command // had got stuck and all media operations were queued up behind it. If necessary, we // should serialise the ones that need to be serialised but then be able to interrupt // them with another load() which will cancel the pending one, but since we don't call // load() explicitly, it shouldn't be a problem. this.remoteVideoElement.srcObject = this.remoteStream; logger.info("playing remote video. stream active? " + this.remoteStream.active); try { await this.remoteVideoElement.play(); } catch (e) { logger.info("Failed to play remote video element", e); } } onHangupReceived = (msg) => { logger.debug("Hangup received for call ID " + this.callId); // party ID must match (our chosen partner hanging up the call) or be undefined (we haven't chosen // a partner yet but we're treating the hangup as a reject as per VoIP v0) if (this.partyIdMatches(msg) || this.state === CallState.Ringing) { // default reason is user_hangup this.terminate(CallParty.Remote, msg.reason || CallErrorCode.UserHangup, true); } else { logger.info(`Ignoring message from party ID ${msg.party_id}: our partner is ${this.opponentPartyId}`); } }; onRejectReceived = (msg) => { logger.debug("Reject received for call ID " + this.callId); // No need to check party_id for reject because if we'd received either // an answer or reject, we wouldn't be in state InviteSent const shouldTerminate = ( // reject events also end the call if it's ringing: it's another of // our devices rejecting the call. ([CallState.InviteSent, CallState.Ringing].includes(this.state)) || // also if we're in the init state and it's an inbound call, since // this means we just haven't entered the ringing state yet this.state === CallState.Fledgling && this.direction === CallDirection.Inbound ); if (shouldTerminate) { this.terminate(CallParty.Remote, CallErrorCode.UserHangup, true); } else { logger.debug(`Call is in state: ${this.state}: ignoring reject`); } }; onAnsweredElsewhere = (msg) => { logger.debug("Call ID " + this.callId + " answered elsewhere"); this.terminate(CallParty.Remote, CallErrorCode.AnsweredElsewhere, true); }; setState(state: CallState) { const oldState = this.state; this.state = state; this.emit(CallEvent.State, state, oldState); } /** * Internal * @param {string} eventType * @param {Object} content * @return {Promise} */ private sendVoipEvent(eventType: string, content: object) { return this.client.sendEvent(this.roomId, eventType, Object.assign({}, content, { version: VOIP_PROTO_VERSION, call_id: this.callId, party_id: this.ourPartyId, })); } queueCandidate(content: RTCIceCandidate) { // Sends candidates with are sent in a special way because we try to amalgamate // them into one message this.candidateSendQueue.push(content); // Don't send the ICE candidates yet if the call is in the ringing state: this // means we tried to pick (ie. started generating candidates) and then failed to // send the answer and went back to the ringing state. Queue up the candidates // to send if we sucessfully send the answer. // Equally don't send if we haven't yet sent the answer because we can send the // first batch of candidates along with the answer if (this.state === CallState.Ringing || !this.inviteOrAnswerSent) return; // MSC2746 reccomends these values (can be quite long when calling because the // callee will need a while to answer the call) const delay = this.direction === CallDirection.Inbound ? 500 : 2000; if (this.candidateSendTries === 0) { setTimeout(() => { this.sendCandidateQueue(); }, delay); } } /* * Transfers this call to another user */ async transfer(targetUserId: string) { // Fetch the target user's global profile info: their room avatar / displayname // could be different in whatever room we shae with them. const profileInfo = await this.client.getProfileInfo(targetUserId); const replacementId = genCallID(); const body = { replacement_id: genCallID(), target_user: { id: targetUserId, display_name: profileInfo.display_name, avatar_url: profileInfo.avatar_url, }, create_call: replacementId, } as MCallReplacesEvent; await this.sendVoipEvent(EventType.CallReplaces, body); await this.terminate(CallParty.Local, CallErrorCode.Replaced, true); } /* * Transfers this call to the target call, effectively 'joining' the * two calls (so the remote parties on each call are connected together). */ async transferToCall(transferTargetCall?: MatrixCall) { const targetProfileInfo = await this.client.getProfileInfo(transferTargetCall.getOpponentMember().userId); const transfereeProfileInfo = await this.client.getProfileInfo(this.getOpponentMember().userId); const newCallId = genCallID(); const bodyToTransferTarget = { // the replacements on each side have their own ID, and it's distinct from the // ID of the new call (but we can use the same function to generate it) replacement_id: genCallID(), target_user: { id: this.getOpponentMember().userId, display_name: transfereeProfileInfo.display_name, avatar_url: transfereeProfileInfo.avatar_url, }, await_call: newCallId, } as MCallReplacesEvent; await transferTargetCall.sendVoipEvent(EventType.CallReplaces, bodyToTransferTarget); const bodyToTransferee = { replacement_id: genCallID(), target_user: { id: transferTargetCall.getOpponentMember().userId, display_name: targetProfileInfo.display_name, avatar_url: targetProfileInfo.avatar_url, }, create_call: newCallId, } as MCallReplacesEvent; await this.sendVoipEvent(EventType.CallReplaces, bodyToTransferee); await this.terminate(CallParty.Local, CallErrorCode.Replaced, true); await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Replaced, true); } private async terminate(hangupParty: CallParty, hangupReason: CallErrorCode, shouldEmit: boolean) { if (this.callHasEnded()) return; this.callStatsAtEnd = await this.collectCallStats(); if (this.inviteTimeout) { clearTimeout(this.inviteTimeout); this.inviteTimeout = null; } const remoteVid = this.getRemoteVideoElement(); const remoteAud = this.getRemoteAudioElement(); const localVid = this.getLocalVideoElement(); if (remoteVid) { remoteVid.pause(); remoteVid.srcObject = null; } if (remoteAud) { remoteAud.pause(); remoteAud.srcObject = null; try { // As per comment in playRemoteAudio, setting the sink ID back to the default // once the call is over makes setSinkId work reliably. await this.remoteAudioElement.setSinkId('') } catch (e) { logger.warn("Failed to set sink ID back to default"); } } if (localVid) { localVid.pause(); localVid.srcObject = null; } this.hangupParty = hangupParty; this.hangupReason = hangupReason; this.setState(CallState.Ended); this.stopAllMedia(); if (this.peerConn && this.peerConn.signalingState !== 'closed') { this.peerConn.close(); } if (shouldEmit) { this.emit(CallEvent.Hangup, this); } } private stopAllMedia() { logger.debug(`stopAllMedia (stream=${this.localAVStream})`); if (this.localAVStream) { for (const track of this.localAVStream.getTracks()) { track.stop(); } } if (this.screenSharingStream) { for (const track of this.screenSharingStream.getTracks()) { track.stop(); } } if (this.remoteStream) { for (const track of this.remoteStream.getTracks()) { track.stop(); } } } private checkForErrorListener() { if (this.listeners("error").length === 0) { throw new Error( "You MUST attach an error listener using call.on('error', function() {})", ); } } private async sendCandidateQueue() { if (this.candidateSendQueue.length === 0) { return; } const cands = this.candidateSendQueue; this.candidateSendQueue = []; ++this.candidateSendTries; const content = { candidates: cands, }; logger.debug("Attempting to send " + cands.length + " candidates"); try { await this.sendVoipEvent(EventType.CallCandidates, content); } catch (error) { // don't retry this event: we'll send another one later as we might // have more candidates by then. if (error.event) this.client.cancelPendingEvent(error.event); // put all the candidates we failed to send back in the queue this.candidateSendQueue.push(...cands); if (this.candidateSendTries > 5) { logger.debug( "Failed to send candidates on attempt " + this.candidateSendTries + ". Giving up on this call.", error, ); const code = CallErrorCode.SignallingFailed; const message = "Signalling failed"; this.emit(CallEvent.Error, new CallError(code, message, error)); this.hangup(code, false); return; } const delayMs = 500 * Math.pow(2, this.candidateSendTries); ++this.candidateSendTries; logger.debug("Failed to send candidates. Retrying in " + delayMs + "ms", error); setTimeout(() => { this.sendCandidateQueue(); }, delayMs); } } private async placeCallWithConstraints(constraints: MediaStreamConstraints) { logger.log("Getting user media with constraints", constraints); // XXX Find a better way to do this this.client._callEventHandler.calls.set(this.callId, this); this.setState(CallState.WaitLocalMedia); this.direction = CallDirection.Outbound; this.config = constraints; // make sure we have valid turn creds. Unless something's gone wrong, it should // poll and keep the credentials valid so this should be instant. const haveTurnCreds = await this.client._checkTurnServers(); if (!haveTurnCreds) { logger.warn("Failed to get TURN credentials! Proceeding with call anyway..."); } // create the peer connection now so it can be gathering candidates while we get user // media (assuming a candidate pool size is configured) this.peerConn = this.createPeerConnection(); try { const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); this.gotUserMediaForInvite(mediaStream); } catch (e) { this.getUserMediaFailed(e); return; } } private createPeerConnection(): RTCPeerConnection { const pc = new window.RTCPeerConnection({ iceTransportPolicy: this.forceTURN ? 'relay' : undefined, iceServers: this.turnServers, iceCandidatePoolSize: this.client._iceCandidatePoolSize, }); // 'connectionstatechange' would be better, but firefox doesn't implement that. pc.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChanged); pc.addEventListener('signalingstatechange', this.onSignallingStateChanged); pc.addEventListener('icecandidate', this.gotLocalIceCandidate); pc.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange); pc.addEventListener('track', this.onTrack); pc.addEventListener('negotiationneeded', this.onNegotiationNeeded); return pc; } private partyIdMatches(msg): boolean { // They must either match or both be absent (in which case opponentPartyId will be null) // Also we ignore party IDs on the invite/offer if the version is 0, so we must do the same // here and use null if the version is 0 (woe betide any opponent sending messages in the // same call with different versions) const msgPartyId = msg.version === 0 ? null : msg.party_id || null; return msgPartyId === this.opponentPartyId; } // Commits to an opponent for the call // ev: An invite or answer event private chooseOpponent(ev: MatrixEvent) { // I choo-choo-choose you const msg = ev.getContent(); logger.debug(`Choosing party ID ${msg.party_id} for call ID ${this.callId}`); this.opponentVersion = msg.version; if (this.opponentVersion === 0) { // set to null to indicate that we've chosen an opponent, but because // they're v0 they have no party ID (even if they sent one, we're ignoring it) this.opponentPartyId = null; } else { // set to their party ID, or if they're naughty and didn't send one despite // not being v0, set it to null to indicate we picked an opponent with no // party ID this.opponentPartyId = msg.party_id || null; } this.opponentCaps = msg.capabilities || {}; this.opponentMember = ev.sender; } private async addBufferedIceCandidates() { const bufferedCands = this.remoteCandidateBuffer.get(this.opponentPartyId); if (bufferedCands) { logger.info(`Adding ${bufferedCands.length} buffered candidates for opponent ${this.opponentPartyId}`); await this.addIceCandidates(bufferedCands); } this.remoteCandidateBuffer = null; } private async addIceCandidates(cands: RTCIceCandidate[]) { for (const cand of cands) { if ( (cand.sdpMid === null || cand.sdpMid === undefined) && (cand.sdpMLineIndex === null || cand.sdpMLineIndex === undefined) ) { logger.debug("Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex"); continue; } logger.debug("Call " + this.callId + " got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate); try { await this.peerConn.addIceCandidate(cand); } catch (err) { if (!this.ignoreOffer) { logger.info("Failed to add remote ICE candidate", err); } } } } } function setTracksEnabled(tracks: Array, enabled: boolean) { for (let i = 0; i < tracks.length; i++) { tracks[i].enabled = enabled; } } function getUserMediaContraints(type: ConstraintsType) { const isWebkit = !!navigator.webkitGetUserMedia; switch (type) { case ConstraintsType.Audio: { return { audio: { deviceId: audioInput ? {ideal: audioInput} : undefined, }, video: false, }; } case ConstraintsType.Video: { return { audio: { deviceId: audioInput ? {ideal: audioInput} : undefined, }, video: { deviceId: videoInput ? {ideal: videoInput} : undefined, /* We want 640x360. Chrome will give it only if we ask exactly, FF refuses entirely if we ask exactly, so have to ask for ideal instead XXX: Is this still true? */ width: isWebkit ? { exact: 640 } : { ideal: 640 }, height: isWebkit ? { exact: 360 } : { ideal: 360 }, }, }; } } } async function getScreenshareContraints(selectDesktopCapturerSource?: () => Promise) { if (window.electron?.getDesktopCapturerSources && selectDesktopCapturerSource) { // We have access to getDesktopCapturerSources() logger.debug("Electron getDesktopCapturerSources() is available..."); const selectedSource = await selectDesktopCapturerSource(); if (!selectedSource) return null; return { audio: false, video: { mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: selectedSource.id, }, }, }; } else { // We do not have access to the Electron desktop capturer, // therefore we can assume we are on the web logger.debug("Electron desktopCapturer is not available..."); return { audio: false, video: true, }; } } let audioOutput: string; let audioInput: string; let videoInput: string; /** * Set an audio output device to use for MatrixCalls * @function * @param {string=} deviceId the identifier for the device * undefined treated as unset */ export function setAudioOutput(deviceId: string) { audioOutput = deviceId; } /** * Set an audio input device to use for MatrixCalls * @function * @param {string=} deviceId the identifier for the device * undefined treated as unset */ export function setAudioInput(deviceId: string) { audioInput = deviceId; } /** * Set a video input device to use for MatrixCalls * @function * @param {string=} deviceId the identifier for the device * undefined treated as unset */ export function setVideoInput(deviceId: string) { videoInput = deviceId; } /** * Create a new Matrix call for the browser. * @param {MatrixClient} client The client instance to use. * @param {string} roomId The room the call is in. * @param {Object?} options DEPRECATED optional options map. * @param {boolean} options.forceTURN DEPRECATED whether relay through TURN should be * forced. This option is deprecated - use opts.forceTURN when creating the matrix client * since it's only possible to set this option on outbound calls. * @return {MatrixCall} the call or null if the browser doesn't support calling. */ export function createNewMatrixCall(client: any, roomId: string, options?: CallOpts) { // typeof prevents Node from erroring on an undefined reference if (typeof(window) === 'undefined' || typeof(document) === 'undefined') { // NB. We don't log here as apps try to create a call object as a test for // whether calls are supported, so we shouldn't fill the logs up. return null; } // Firefox throws on so little as accessing the RTCPeerConnection when operating in // a secure mode. There's some information at https://bugzilla.mozilla.org/show_bug.cgi?id=1542616 // though the concern is that the browser throwing a SecurityError will brick the // client creation process. try { const supported = Boolean( window.RTCPeerConnection || window.RTCSessionDescription || window.RTCIceCandidate || navigator.mediaDevices, ); if (!supported) { logger.error("WebRTC is not supported in this browser / environment"); return null; } } catch (e) { logger.error("Exception thrown when trying to access WebRTC", e); return null; } const optionsForceTURN = options ? options.forceTURN : false; const opts = { client: client, roomId: roomId, turnServers: client.getTurnServers(), // call level options forceTURN: client._forceTURN || optionsForceTURN, }; const call = new MatrixCall(opts); client.reEmitter.reEmit(call, Object.values(CallEvent)); return call; } matrix-js-sdk-9.11.0/src/webrtc/callEventHandler.ts000066400000000000000000000270051403504662500221500ustar00rootroot00000000000000/* Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import MatrixEvent from '../models/event'; import {logger} from '../logger'; import { createNewMatrixCall, MatrixCall, CallErrorCode, CallState, CallDirection } from './call'; import { EventType } from '../@types/event'; import { MatrixClient } from '../client'; // Don't ring unless we'd be ringing for at least 3 seconds: the user needs some // time to press the 'accept' button const RING_GRACE_PERIOD = 3000; export class CallEventHandler { client: MatrixClient; calls: Map; callEventBuffer: MatrixEvent[]; candidateEventsByCall: Map>; constructor(client: MatrixClient) { this.client = client; this.calls = new Map(); // The sync code always emits one event at a time, so it will patiently // wait for us to finish processing a call invite before delivering the // next event, even if that next event is a hangup. We therefore accumulate // all our call events and then process them on the 'sync' event, ie. // each time a sync has completed. This way, we can avoid emitting incoming // call events if we get both the invite and answer/hangup in the same sync. // This happens quite often, eg. replaying sync from storage, catchup sync // after loading and after we've been offline for a bit. this.callEventBuffer = []; this.candidateEventsByCall = new Map>(); this.client.on("sync", this.evaluateEventBuffer); this.client.on("event", this.onEvent); } public stop() { this.client.removeListener("sync", this.evaluateEventBuffer); this.client.removeListener("event", this.onEvent); } private evaluateEventBuffer = () => { if (this.client.getSyncState() === "SYNCING") { // don't process any events until they are all decrypted if (this.callEventBuffer.some((e) => e.isBeingDecrypted())) return; const ignoreCallIds = new Set(); // inspect the buffer and mark all calls which have been answered // or hung up before passing them to the call event handler. for (const ev of this.callEventBuffer) { if (ev.getType() === EventType.CallAnswer || ev.getType() === EventType.CallHangup) { ignoreCallIds.add(ev.getContent().call_id); } } // now loop through the buffer chronologically and inject them for (const e of this.callEventBuffer) { if ( e.getType() === EventType.CallInvite && ignoreCallIds.has(e.getContent().call_id) ) { // This call has previously been answered or hung up: ignore it continue; } try { this.handleCallEvent(e); } catch (e) { logger.error("Caught exception handling call event", e); } } this.callEventBuffer = []; } } private onEvent = (event: MatrixEvent) => { // any call events or ones that might be once they're decrypted if (event.getType().indexOf("m.call.") === 0 || event.isBeingDecrypted()) { // queue up for processing once all events from this sync have been // processed (see above). this.callEventBuffer.push(event); } if (event.isBeingDecrypted() || event.isDecryptionFailure()) { // add an event listener for once the event is decrypted. event.once("Event.decrypted", () => { if (event.getType().indexOf("m.call.") === -1) return; if (this.callEventBuffer.includes(event)) { // we were waiting for that event to decrypt, so recheck the buffer this.evaluateEventBuffer(); } else { // This one wasn't buffered so just run the event handler for it // straight away try { this.handleCallEvent(event); } catch (e) { logger.error("Caught exception handling call event", e); } } }); } } private handleCallEvent(event: MatrixEvent) { const content = event.getContent(); let call = content.call_id ? this.calls.get(content.call_id) : undefined; //console.info("RECV %s content=%s", event.getType(), JSON.stringify(content)); if (event.getType() === EventType.CallInvite) { if (event.getSender() === this.client.credentials.userId) { return; // ignore invites you send } if (event.getLocalAge() > content.lifetime - RING_GRACE_PERIOD) { return; // expired call } if (call && call.state === CallState.Ended) { return; // stale/old invite event } if (call) { logger.log( `WARN: Already have a MatrixCall with id ${content.call_id} but got an ` + `invite. Clobbering.`, ); } const timeUntilTurnCresExpire = this.client.getTurnServersExpiry() - Date.now(); logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); call = createNewMatrixCall(this.client, event.getRoomId(), { forceTURN: this.client._forceTURN, }); 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); this.calls.set(call.callId, call); // if we stashed candidate events for that call ID, play them back now if (this.candidateEventsByCall.get(call.callId)) { for (const ev of this.candidateEventsByCall.get(call.callId)) { call.onRemoteIceCandidatesReceived(ev); } } // Were we trying to call that user (room)? let existingCall; for (const thisCall of this.calls.values()) { const isCalling = [CallState.WaitLocalMedia, CallState.CreateOffer, CallState.InviteSent].includes( thisCall.state, ); if ( call.roomId === thisCall.roomId && thisCall.direction === CallDirection.Outbound && isCalling ) { 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 === CallState.WaitLocalMedia || existingCall.state === CallState.CreateOffer || 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(CallErrorCode.Replaced, true); } } else { this.client.emit("Call.incoming", call); } } else if (event.getType() === EventType.CallAnswer) { if (!call) { return; } if (event.getSender() === this.client.credentials.userId) { if (call.state === CallState.Ringing) { call.onAnsweredElsewhere(content); } } else { call.onAnswerReceived(event); } } else if (event.getType() === EventType.CallCandidates) { if (event.getSender() === this.client.credentials.userId) { return; } if (!call) { // store the candidates; we may get a call eventually. if (!this.candidateEventsByCall.has(content.call_id)) { this.candidateEventsByCall.set(content.call_id, []); } this.candidateEventsByCall.get(content.call_id).push(event); } else { call.onRemoteIceCandidatesReceived(event); } } else if ([EventType.CallHangup, EventType.CallReject].includes(event.getType())) { // 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 = createNewMatrixCall(this.client, event.getRoomId()); if (call) { call.callId = content.call_id; call.initWithHangup(event); this.calls.set(content.call_id, call); } } else { if (call.state !== CallState.Ended) { if (event.getType() === EventType.CallHangup) { call.onHangupReceived(content); } else { call.onRejectReceived(content); } this.calls.delete(content.call_id); } } } else if (event.getType() === EventType.CallSelectAnswer) { if (!call) return; if (event.getContent().party_id === call.ourPartyId) { // Ignore remote echo return; } call.onSelectAnswerReceived(event); } else if (event.getType() === EventType.CallNegotiate) { if (!call) return; if (event.getContent().party_id === call.ourPartyId) { // Ignore remote echo return; } call.onNegotiateReceived(event); } } } matrix-js-sdk-9.11.0/src/webrtc/callEventTypes.ts000066400000000000000000000014601403504662500216740ustar00rootroot00000000000000// allow camelcase as these are events type that go onto the wire /* eslint-disable camelcase */ interface CallOfferAnswer { type: string; sdp: string; } export interface CallCapabilities { 'm.call.transferee': boolean; } export interface MCallAnswer { answer: CallOfferAnswer; capabilities: CallCapabilities; } export interface MCallOfferNegotiate { offer: CallOfferAnswer; description: CallOfferAnswer; lifetime: number; capabilities: CallCapabilities; } export interface MCallReplacesTarget { id: string; display_name: string; avatar_url: string; } export interface MCallReplacesEvent { replacement_id: string; target_user: MCallReplacesTarget; create_call: string; await_call: string; target_room: string; } /* eslint-enable camelcase */ matrix-js-sdk-9.11.0/tsconfig.json000066400000000000000000000006021403504662500170110ustar00rootroot00000000000000{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true, "esModuleInterop": true, "module": "commonjs", "moduleResolution": "node", "target": "es2016", "noImplicitAny": false, "sourceMap": true, "outDir": "./lib", "declaration": true, "types": [ "node" ] }, "include": [ "./src/**/*.ts" ] } matrix-js-sdk-9.11.0/yarn.lock000066400000000000000000012332351403504662500161400ustar00rootroot00000000000000# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 "@babel/cli@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.12.10.tgz#67a1015b1cd505bde1696196febf910c4c339a48" integrity sha512-+y4ZnePpvWs1fc/LhZRTHkTesbXkyBYuOB+5CyodZqrEuETXi3zOVfpAQIdgC3lXbHLTDG9dQosxR9BhvLKDLQ== dependencies: commander "^4.0.1" convert-source-map "^1.1.0" fs-readdir-recursive "^1.1.0" glob "^7.0.0" lodash "^4.17.19" make-dir "^2.1.0" slash "^2.0.0" source-map "^0.5.0" optionalDependencies: "@nicolo-ribaudo/chokidar-2" "2.1.8-no-fsevents" chokidar "^3.4.0" "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== dependencies: "@babel/highlight" "^7.10.4" "@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" integrity sha512-YaxPMGs/XIWtYqrdEOZOCPsVWfEoriXopnsz3/i7apYPXQ3698UFhS6dVT1KN5qOsWmVgw/FOrmQgpRaZayGsw== "@babel/core@^7.1.0", "@babel/core@^7.12.10", "@babel/core@^7.7.5": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.10.tgz#b79a2e1b9f70ed3d84bbfb6d8c4ef825f606bccd" integrity sha512-eTAlQKq65zHfkHZV0sIVODCPGVgoo1HdBlbSLi9CqOzuZanMv2ihzY+4paiKr1mH+XmYESMAmJ/dpZ68eN6d8w== dependencies: "@babel/code-frame" "^7.10.4" "@babel/generator" "^7.12.10" "@babel/helper-module-transforms" "^7.12.1" "@babel/helpers" "^7.12.5" "@babel/parser" "^7.12.10" "@babel/template" "^7.12.7" "@babel/traverse" "^7.12.10" "@babel/types" "^7.12.10" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.1" json5 "^2.1.2" lodash "^4.17.19" semver "^5.4.1" source-map "^0.5.0" "@babel/generator@^7.12.10", "@babel/generator@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.11.tgz#98a7df7b8c358c9a37ab07a24056853016aba3af" integrity sha512-Ggg6WPOJtSi8yYQvLVjG8F/TlpWDlKx0OpS4Kt+xMQPs5OaGYWy+v1A+1TvxI6sAMGZpKWWoAQ1DaeQbImlItA== dependencies: "@babel/types" "^7.12.11" jsesc "^2.5.1" source-map "^0.5.0" "@babel/helper-annotate-as-pure@^7.10.4": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.10.tgz#54ab9b000e60a93644ce17b3f37d313aaf1d115d" integrity sha512-XplmVbC1n+KY6jL8/fgLVXXUauDIB+lD5+GsQEh6F6GBF1dq1qy4DP4yXWzDKcoqXB3X58t61e85Fitoww4JVQ== dependencies: "@babel/types" "^7.12.10" "@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.10.4.tgz#bb0b75f31bf98cbf9ff143c1ae578b87274ae1a3" integrity sha512-L0zGlFrGWZK4PbT8AszSfLTM5sDU1+Az/En9VrdT8/LmEiJt4zXt+Jve9DCAnQcbqDhCI+29y/L93mrDzddCcg== dependencies: "@babel/helper-explode-assignable-expression" "^7.10.4" "@babel/types" "^7.10.4" "@babel/helper-compilation-targets@^7.12.5": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.5.tgz#cb470c76198db6a24e9dbc8987275631e5d29831" integrity sha512-+qH6NrscMolUlzOYngSBMIOQpKUGPPsc61Bu5W10mg84LxZ7cmvnBHzARKbDoFxVvqqAbj6Tg6N7bSrWSPXMyw== dependencies: "@babel/compat-data" "^7.12.5" "@babel/helper-validator-option" "^7.12.1" browserslist "^4.14.5" semver "^5.5.0" "@babel/helper-create-class-features-plugin@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz#3c45998f431edd4a9214c5f1d3ad1448a6137f6e" integrity sha512-hkL++rWeta/OVOBTRJc9a5Azh5mt5WgZUGAKMD8JM141YsE08K//bp1unBBieO6rUKkIPyUE0USQ30jAy3Sk1w== dependencies: "@babel/helper-function-name" "^7.10.4" "@babel/helper-member-expression-to-functions" "^7.12.1" "@babel/helper-optimise-call-expression" "^7.10.4" "@babel/helper-replace-supers" "^7.12.1" "@babel/helper-split-export-declaration" "^7.10.4" "@babel/helper-create-regexp-features-plugin@^7.12.1": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.7.tgz#2084172e95443fa0a09214ba1bb328f9aea1278f" integrity sha512-idnutvQPdpbduutvi3JVfEgcVIHooQnhvhx0Nk9isOINOIGYkZea1Pk2JlJRiUnMefrlvr0vkByATBY/mB4vjQ== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" regexpu-core "^4.7.1" "@babel/helper-define-map@^7.10.4": version "7.10.5" resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.10.5.tgz#b53c10db78a640800152692b13393147acb9bb30" integrity sha512-fMw4kgFB720aQFXSVaXr79pjjcW5puTCM16+rECJ/plGS+zByelE8l9nCpV1GibxTnFVmUuYG9U8wYfQHdzOEQ== dependencies: "@babel/helper-function-name" "^7.10.4" "@babel/types" "^7.10.5" lodash "^4.17.19" "@babel/helper-explode-assignable-expression@^7.10.4": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.12.1.tgz#8006a466695c4ad86a2a5f2fb15b5f2c31ad5633" integrity sha512-dmUwH8XmlrUpVqgtZ737tK88v07l840z9j3OEhCLwKTkjlvKpfqXVIZ0wpK3aeOxspwGrf/5AP5qLx4rO3w5rA== dependencies: "@babel/types" "^7.12.1" "@babel/helper-function-name@^7.10.4", "@babel/helper-function-name@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.11.tgz#1fd7738aee5dcf53c3ecff24f1da9c511ec47b42" integrity sha512-AtQKjtYNolKNi6nNNVLQ27CP6D9oFR6bq/HPYSizlzbp7uC1M59XJe8L+0uXjbIaZaUJF99ruHqVGiKXU/7ybA== dependencies: "@babel/helper-get-function-arity" "^7.12.10" "@babel/template" "^7.12.7" "@babel/types" "^7.12.11" "@babel/helper-get-function-arity@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" integrity sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag== dependencies: "@babel/types" "^7.12.10" "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e" integrity sha512-wljroF5PgCk2juF69kanHVs6vrLwIPNp6DLD+Lrl3hoQ3PpPPikaDRNFA+0t81NOoMt2DL6WW/mdU8k4k6ZzuA== dependencies: "@babel/types" "^7.10.4" "@babel/helper-member-expression-to-functions@^7.12.1", "@babel/helper-member-expression-to-functions@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" integrity sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw== dependencies: "@babel/types" "^7.12.7" "@babel/helper-module-imports@^7.12.1", "@babel/helper-module-imports@^7.12.5": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb" integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA== dependencies: "@babel/types" "^7.12.5" "@babel/helper-module-transforms@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c" integrity sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w== dependencies: "@babel/helper-module-imports" "^7.12.1" "@babel/helper-replace-supers" "^7.12.1" "@babel/helper-simple-access" "^7.12.1" "@babel/helper-split-export-declaration" "^7.11.0" "@babel/helper-validator-identifier" "^7.10.4" "@babel/template" "^7.10.4" "@babel/traverse" "^7.12.1" "@babel/types" "^7.12.1" lodash "^4.17.19" "@babel/helper-optimise-call-expression@^7.10.4", "@babel/helper-optimise-call-expression@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz#94ca4e306ee11a7dd6e9f42823e2ac6b49881e2d" integrity sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ== dependencies: "@babel/types" "^7.12.10" "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== "@babel/helper-remap-async-to-generator@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.1.tgz#8c4dbbf916314f6047dc05e6a2217074238347fd" integrity sha512-9d0KQCRM8clMPcDwo8SevNs+/9a8yWVVmaE80FGJcEP8N1qToREmWEGnBn8BUlJhYRFz6fqxeRL1sl5Ogsed7A== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-wrap-function" "^7.10.4" "@babel/types" "^7.12.1" "@babel/helper-replace-supers@^7.12.1": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.12.11.tgz#ea511658fc66c7908f923106dd88e08d1997d60d" integrity sha512-q+w1cqmhL7R0FNzth/PLLp2N+scXEK/L2AHbXUyydxp828F4FEa5WcVoqui9vFRiHDQErj9Zof8azP32uGVTRA== dependencies: "@babel/helper-member-expression-to-functions" "^7.12.7" "@babel/helper-optimise-call-expression" "^7.12.10" "@babel/traverse" "^7.12.10" "@babel/types" "^7.12.11" "@babel/helper-simple-access@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA== dependencies: "@babel/types" "^7.12.1" "@babel/helper-skip-transparent-expression-wrappers@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf" integrity sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA== dependencies: "@babel/types" "^7.12.1" "@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0", "@babel/helper-split-export-declaration@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.11.tgz#1b4cc424458643c47d37022223da33d76ea4603a" integrity sha512-LsIVN8j48gHgwzfocYUSkO/hjYAOJqlpJEc7tGXcIm4cubjVUf8LGW6eWRyxEu7gA25q02p0rQUWoCI33HNS5g== dependencies: "@babel/types" "^7.12.11" "@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== "@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.11.tgz#d66cb8b7a3e7fe4c6962b32020a131ecf0847f4f" integrity sha512-TBFCyj939mFSdeX7U7DDj32WtzYY7fDcalgq8v3fBZMNOJQNn7nOYzMaUCiPxPYfCup69mtIpqlKgMZLvQ8Xhw== "@babel/helper-wrap-function@^7.10.4": version "7.12.3" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.12.3.tgz#3332339fc4d1fbbf1c27d7958c27d34708e990d9" integrity sha512-Cvb8IuJDln3rs6tzjW3Y8UeelAOdnpB8xtQ4sme2MSZ9wOxrbThporC0y/EtE16VAtoyEfLM404Xr1e0OOp+ow== dependencies: "@babel/helper-function-name" "^7.10.4" "@babel/template" "^7.10.4" "@babel/traverse" "^7.10.4" "@babel/types" "^7.10.4" "@babel/helpers@^7.12.5": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA== dependencies: "@babel/template" "^7.10.4" "@babel/traverse" "^7.12.5" "@babel/types" "^7.12.5" "@babel/highlight@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143" integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA== dependencies: "@babel/helper-validator-identifier" "^7.10.4" chalk "^2.0.0" js-tokens "^4.0.0" "@babel/parser@^7.1.0", "@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.2.3", "@babel/parser@^7.7.0", "@babel/parser@^7.9.4": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg== "@babel/plugin-proposal-async-generator-functions@^7.12.1": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.12.tgz#04b8f24fd4532008ab4e79f788468fd5a8476566" integrity sha512-nrz9y0a4xmUrRq51bYkWJIO5SBZyG2ys2qinHsN0zHDHVsUaModrkpyWWWXfGqYQmOL3x9sQIcTNN/pBGpo09A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-remap-async-to-generator" "^7.12.1" "@babel/plugin-syntax-async-generators" "^7.8.0" "@babel/plugin-proposal-class-properties@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz#a082ff541f2a29a4821065b8add9346c0c16e5de" integrity sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w== dependencies: "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-proposal-dynamic-import@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz#43eb5c2a3487ecd98c5c8ea8b5fdb69a2749b2dc" integrity sha512-a4rhUSZFuq5W8/OO8H7BL5zspjnc1FLd9hlOxIK/f7qG4a0qsqk8uvF/ywgBA8/OmjsapjpvaEOYItfGG1qIvQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-dynamic-import" "^7.8.0" "@babel/plugin-proposal-export-namespace-from@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.1.tgz#8b9b8f376b2d88f5dd774e4d24a5cc2e3679b6d4" integrity sha512-6CThGf0irEkzujYS5LQcjBx8j/4aQGiVv7J9+2f7pGfxqyKh3WnmVJYW3hdrQjyksErMGBPQrCnHfOtna+WLbw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" "@babel/plugin-proposal-json-strings@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.12.1.tgz#d45423b517714eedd5621a9dfdc03fa9f4eb241c" integrity sha512-GoLDUi6U9ZLzlSda2Df++VSqDJg3CG+dR0+iWsv6XRw1rEq+zwt4DirM9yrxW6XWaTpmai1cWJLMfM8qQJf+yw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-json-strings" "^7.8.0" "@babel/plugin-proposal-logical-assignment-operators@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.12.1.tgz#f2c490d36e1b3c9659241034a5d2cd50263a2751" integrity sha512-k8ZmVv0JU+4gcUGeCDZOGd0lCIamU/sMtIiX3UWnUc5yzgq6YUGyEolNYD+MLYKfSzgECPcqetVcJP9Afe/aCA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" "@babel/plugin-proposal-nullish-coalescing-operator@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.12.1.tgz#3ed4fff31c015e7f3f1467f190dbe545cd7b046c" integrity sha512-nZY0ESiaQDI1y96+jk6VxMOaL4LPo/QDHBqL+SF3/vl6dHkTwHlOI8L4ZwuRBHgakRBw5zsVylel7QPbbGuYgg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" "@babel/plugin-proposal-numeric-separator@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.7.tgz#8bf253de8139099fea193b297d23a9d406ef056b" integrity sha512-8c+uy0qmnRTeukiGsjLGy6uVs/TFjJchGXUeBqlG4VWYOdJWkhhVPdQ3uHwbmalfJwv2JsV0qffXP4asRfL2SQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-numeric-separator" "^7.10.4" "@babel/plugin-proposal-object-rest-spread@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz#def9bd03cea0f9b72283dac0ec22d289c7691069" integrity sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-object-rest-spread" "^7.8.0" "@babel/plugin-transform-parameters" "^7.12.1" "@babel/plugin-proposal-optional-catch-binding@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.12.1.tgz#ccc2421af64d3aae50b558a71cede929a5ab2942" integrity sha512-hFvIjgprh9mMw5v42sJWLI1lzU5L2sznP805zeT6rySVRA0Y18StRhDqhSxlap0oVgItRsB6WSROp4YnJTJz0g== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" "@babel/plugin-proposal-optional-chaining@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.7.tgz#e02f0ea1b5dc59d401ec16fb824679f683d3303c" integrity sha512-4ovylXZ0PWmwoOvhU2vhnzVNnm88/Sm9nx7V8BPgMvAzn5zDou3/Awy0EjglyubVHasJj+XCEkr/r1X3P5elCA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" "@babel/plugin-syntax-optional-chaining" "^7.8.0" "@babel/plugin-proposal-private-methods@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.1.tgz#86814f6e7a21374c980c10d38b4493e703f4a389" integrity sha512-mwZ1phvH7/NHK6Kf8LP7MYDogGV+DKB1mryFOEwx5EBNQrosvIczzZFTUmWaeujd5xT6G1ELYWUz3CutMhjE1w== dependencies: "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-proposal-unicode-property-regex@^7.12.1", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.1.tgz#2a183958d417765b9eae334f47758e5d6a82e072" integrity sha512-MYq+l+PvHuw/rKUz1at/vb6nCnQ2gmJBNaM62z0OgH7B2W1D9pvkpYtlti9bGtizNIU1K3zm4bZF9F91efVY0w== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-async-generators@^7.8.0", "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== dependencies: "@babel/helper-plugin-utils" "^7.8.0" "@babel/plugin-syntax-bigint@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== dependencies: "@babel/helper-plugin-utils" "^7.8.0" "@babel/plugin-syntax-class-properties@^7.12.1", "@babel/plugin-syntax-class-properties@^7.8.3": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz#bcb297c5366e79bebadef509549cd93b04f19978" integrity sha512-U40A76x5gTwmESz+qiqssqmeEsKvcSyvtgktrm0uzcARAmM9I1jR221f6Oq+GmHrcD+LvZDag1UTOTe2fL3TeA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-dynamic-import@^7.8.0", "@babel/plugin-syntax-dynamic-import@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== dependencies: "@babel/helper-plugin-utils" "^7.8.0" "@babel/plugin-syntax-export-namespace-from@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a" integrity sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q== dependencies: "@babel/helper-plugin-utils" "^7.8.3" "@babel/plugin-syntax-import-meta@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-json-strings@^7.8.0", "@babel/plugin-syntax-json-strings@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== dependencies: "@babel/helper-plugin-utils" "^7.8.0" "@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0", "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== dependencies: "@babel/helper-plugin-utils" "^7.8.0" "@babel/plugin-syntax-numeric-separator@^7.10.4", "@babel/plugin-syntax-numeric-separator@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-object-rest-spread@^7.8.0", "@babel/plugin-syntax-object-rest-spread@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== dependencies: "@babel/helper-plugin-utils" "^7.8.0" "@babel/plugin-syntax-optional-catch-binding@^7.8.0", "@babel/plugin-syntax-optional-catch-binding@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== dependencies: "@babel/helper-plugin-utils" "^7.8.0" "@babel/plugin-syntax-optional-chaining@^7.8.0", "@babel/plugin-syntax-optional-chaining@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== dependencies: "@babel/helper-plugin-utils" "^7.8.0" "@babel/plugin-syntax-top-level-await@^7.12.1", "@babel/plugin-syntax-top-level-await@^7.8.3": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.1.tgz#dd6c0b357ac1bb142d98537450a319625d13d2a0" integrity sha512-i7ooMZFS+a/Om0crxZodrTzNEPJHZrlMVGMTEpFAj6rYY/bKCddB0Dk/YxfPuYXOopuhKk/e1jV6h+WUU9XN3A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-typescript@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.12.1.tgz#460ba9d77077653803c3dd2e673f76d66b4029e5" integrity sha512-UZNEcCY+4Dp9yYRCAHrHDU+9ZXLYaY9MgBXSRLkB9WjYFRR6quJBumfVrEkUxrePPBwFcpWfNKXqVRQQtm7mMA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-arrow-functions@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.1.tgz#8083ffc86ac8e777fbe24b5967c4b2521f3cb2b3" integrity sha512-5QB50qyN44fzzz4/qxDPQMBCTHgxg3n0xRBLJUmBlLoU/sFvxVWGZF/ZUfMVDQuJUKXaBhbupxIzIfZ6Fwk/0A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-async-to-generator@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.12.1.tgz#3849a49cc2a22e9743cbd6b52926d30337229af1" integrity sha512-SDtqoEcarK1DFlRJ1hHRY5HvJUj5kX4qmtpMAm2QnhOlyuMC4TMdCRgW6WXpv93rZeYNeLP22y8Aq2dbcDRM1A== dependencies: "@babel/helper-module-imports" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-remap-async-to-generator" "^7.12.1" "@babel/plugin-transform-block-scoped-functions@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.1.tgz#f2a1a365bde2b7112e0a6ded9067fdd7c07905d9" integrity sha512-5OpxfuYnSgPalRpo8EWGPzIYf0lHBWORCkj5M0oLBwHdlux9Ri36QqGW3/LR13RSVOAoUUMzoPI/jpE4ABcHoA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-block-scoping@^7.12.11": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.12.tgz#d93a567a152c22aea3b1929bb118d1d0a175cdca" integrity sha512-VOEPQ/ExOVqbukuP7BYJtI5ZxxsmegTwzZ04j1aF0dkSypGo9XpDHuOrABsJu+ie+penpSJheDJ11x1BEZNiyQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-classes@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.12.1.tgz#65e650fcaddd3d88ddce67c0f834a3d436a32db6" integrity sha512-/74xkA7bVdzQTBeSUhLLJgYIcxw/dpEpCdRDiHgPJ3Mv6uC11UhjpOhl72CgqbBCmt1qtssCyB2xnJm1+PFjog== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-define-map" "^7.10.4" "@babel/helper-function-name" "^7.10.4" "@babel/helper-optimise-call-expression" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-replace-supers" "^7.12.1" "@babel/helper-split-export-declaration" "^7.10.4" globals "^11.1.0" "@babel/plugin-transform-computed-properties@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.12.1.tgz#d68cf6c9b7f838a8a4144badbe97541ea0904852" integrity sha512-vVUOYpPWB7BkgUWPo4C44mUQHpTZXakEqFjbv8rQMg7TC6S6ZhGZ3otQcRH6u7+adSlE5i0sp63eMC/XGffrzg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-destructuring@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.12.1.tgz#b9a570fe0d0a8d460116413cb4f97e8e08b2f847" integrity sha512-fRMYFKuzi/rSiYb2uRLiUENJOKq4Gnl+6qOv5f8z0TZXg3llUwUhsNNwrwaT/6dUhJTzNpBr+CUvEWBtfNY1cw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-dotall-regex@^7.12.1", "@babel/plugin-transform-dotall-regex@^7.4.4": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.1.tgz#a1d16c14862817b6409c0a678d6f9373ca9cd975" integrity sha512-B2pXeRKoLszfEW7J4Hg9LoFaWEbr/kzo3teWHmtFCszjRNa/b40f9mfeqZsIDLLt/FjwQ6pz/Gdlwy85xNckBA== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-duplicate-keys@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.1.tgz#745661baba295ac06e686822797a69fbaa2ca228" integrity sha512-iRght0T0HztAb/CazveUpUQrZY+aGKKaWXMJ4uf9YJtqxSUe09j3wteztCUDRHs+SRAL7yMuFqUsLoAKKzgXjw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-exponentiation-operator@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.1.tgz#b0f2ed356ba1be1428ecaf128ff8a24f02830ae0" integrity sha512-7tqwy2bv48q+c1EHbXK0Zx3KXd2RVQp6OC7PbwFNt/dPTAV3Lu5sWtWuAj8owr5wqtWnqHfl2/mJlUmqkChKug== dependencies: "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-for-of@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.1.tgz#07640f28867ed16f9511c99c888291f560921cfa" integrity sha512-Zaeq10naAsuHo7heQvyV0ptj4dlZJwZgNAtBYBnu5nNKJoW62m0zKcIEyVECrUKErkUkg6ajMy4ZfnVZciSBhg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-function-name@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.1.tgz#2ec76258c70fe08c6d7da154003a480620eba667" integrity sha512-JF3UgJUILoFrFMEnOJLJkRHSk6LUSXLmEFsA23aR2O5CSLUxbeUX1IZ1YQ7Sn0aXb601Ncwjx73a+FVqgcljVw== dependencies: "@babel/helper-function-name" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-literals@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.1.tgz#d73b803a26b37017ddf9d3bb8f4dc58bfb806f57" integrity sha512-+PxVGA+2Ag6uGgL0A5f+9rklOnnMccwEBzwYFL3EUaKuiyVnUipyXncFcfjSkbimLrODoqki1U9XxZzTvfN7IQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-member-expression-literals@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.1.tgz#496038602daf1514a64d43d8e17cbb2755e0c3ad" integrity sha512-1sxePl6z9ad0gFMB9KqmYofk34flq62aqMt9NqliS/7hPEpURUCMbyHXrMPlo282iY7nAvUB1aQd5mg79UD9Jg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-modules-amd@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.12.1.tgz#3154300b026185666eebb0c0ed7f8415fefcf6f9" integrity sha512-tDW8hMkzad5oDtzsB70HIQQRBiTKrhfgwC/KkJeGsaNFTdWhKNt/BiE8c5yj19XiGyrxpbkOfH87qkNg1YGlOQ== dependencies: "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" "@babel/plugin-transform-modules-commonjs@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.1.tgz#fa403124542636c786cf9b460a0ffbb48a86e648" integrity sha512-dY789wq6l0uLY8py9c1B48V8mVL5gZh/+PQ5ZPrylPYsnAvnEMjqsUXkuoDVPeVK+0VyGar+D08107LzDQ6pag== dependencies: "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-simple-access" "^7.12.1" babel-plugin-dynamic-import-node "^2.3.3" "@babel/plugin-transform-modules-systemjs@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.12.1.tgz#663fea620d593c93f214a464cd399bf6dc683086" integrity sha512-Hn7cVvOavVh8yvW6fLwveFqSnd7rbQN3zJvoPNyNaQSvgfKmDBO9U1YL9+PCXGRlZD9tNdWTy5ACKqMuzyn32Q== dependencies: "@babel/helper-hoist-variables" "^7.10.4" "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-validator-identifier" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" "@babel/plugin-transform-modules-umd@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.12.1.tgz#eb5a218d6b1c68f3d6217b8fa2cc82fec6547902" integrity sha512-aEIubCS0KHKM0zUos5fIoQm+AZUMt1ZvMpqz0/H5qAQ7vWylr9+PLYurT+Ic7ID/bKLd4q8hDovaG3Zch2uz5Q== dependencies: "@babel/helper-module-transforms" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-named-capturing-groups-regex@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.1.tgz#b407f5c96be0d9f5f88467497fa82b30ac3e8753" integrity sha512-tB43uQ62RHcoDp9v2Nsf+dSM8sbNodbEicbQNA53zHz8pWUhsgHSJCGpt7daXxRydjb0KnfmB+ChXOv3oADp1Q== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/plugin-transform-new-target@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.1.tgz#80073f02ee1bb2d365c3416490e085c95759dec0" integrity sha512-+eW/VLcUL5L9IvJH7rT1sT0CzkdUTvPrXC2PXTn/7z7tXLBuKvezYbGdxD5WMRoyvyaujOq2fWoKl869heKjhw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-object-super@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.1.tgz#4ea08696b8d2e65841d0c7706482b048bed1066e" integrity sha512-AvypiGJH9hsquNUn+RXVcBdeE3KHPZexWRdimhuV59cSoOt5kFBmqlByorAeUlGG2CJWd0U+4ZtNKga/TB0cAw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-replace-supers" "^7.12.1" "@babel/plugin-transform-parameters@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.12.1.tgz#d2e963b038771650c922eff593799c96d853255d" integrity sha512-xq9C5EQhdPK23ZeCdMxl8bbRnAgHFrw5EOC3KJUsSylZqdkCaFEXxGSBuTSObOpiiHHNyb82es8M1QYgfQGfNg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-property-literals@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.1.tgz#41bc81200d730abb4456ab8b3fbd5537b59adecd" integrity sha512-6MTCR/mZ1MQS+AwZLplX4cEySjCpnIF26ToWo942nqn8hXSm7McaHQNeGx/pt7suI1TWOWMfa/NgBhiqSnX0cQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-regenerator@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.1.tgz#5f0a28d842f6462281f06a964e88ba8d7ab49753" integrity sha512-gYrHqs5itw6i4PflFX3OdBPMQdPbF4bj2REIUxlMRUFk0/ZOAIpDFuViuxPjUL7YC8UPnf+XG7/utJvqXdPKng== dependencies: regenerator-transform "^0.14.2" "@babel/plugin-transform-reserved-words@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.1.tgz#6fdfc8cc7edcc42b36a7c12188c6787c873adcd8" integrity sha512-pOnUfhyPKvZpVyBHhSBoX8vfA09b7r00Pmm1sH+29ae2hMTKVmSp4Ztsr8KBKjLjx17H0eJqaRC3bR2iThM54A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-runtime@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.12.10.tgz#af0fded4e846c4b37078e8e5d06deac6cd848562" integrity sha512-xOrUfzPxw7+WDm9igMgQCbO3cJKymX7dFdsgRr1eu9n3KjjyU4pptIXbXPseQDquw+W+RuJEJMHKHNsPNNm3CA== dependencies: "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" semver "^5.5.1" "@babel/plugin-transform-shorthand-properties@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.1.tgz#0bf9cac5550fce0cfdf043420f661d645fdc75e3" integrity sha512-GFZS3c/MhX1OusqB1MZ1ct2xRzX5ppQh2JU1h2Pnfk88HtFTM+TWQqJNfwkmxtPQtb/s1tk87oENfXJlx7rSDw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-spread@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.12.1.tgz#527f9f311be4ec7fdc2b79bb89f7bf884b3e1e1e" integrity sha512-vuLp8CP0BE18zVYjsEBZ5xoCecMK6LBMMxYzJnh01rxQRvhNhH1csMMmBfNo5tGpGO+NhdSNW2mzIvBu3K1fng== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" "@babel/plugin-transform-sticky-regex@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.7.tgz#560224613ab23987453948ed21d0b0b193fa7fad" integrity sha512-VEiqZL5N/QvDbdjfYQBhruN0HYjSPjC4XkeqW4ny/jNtH9gcbgaqBIXYEZCNnESMAGs0/K/R7oFGMhOyu/eIxg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-template-literals@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.12.1.tgz#b43ece6ed9a79c0c71119f576d299ef09d942843" integrity sha512-b4Zx3KHi+taXB1dVRBhVJtEPi9h1THCeKmae2qP0YdUHIFhVjtpqqNfxeVAa1xeHVhAy4SbHxEwx5cltAu5apw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-typeof-symbol@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.10.tgz#de01c4c8f96580bd00f183072b0d0ecdcf0dec4b" integrity sha512-JQ6H8Rnsogh//ijxspCjc21YPd3VLVoYtAwv3zQmqAt8YGYUtdo5usNhdl4b9/Vir2kPFZl6n1h0PfUz4hJhaA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-typescript@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.12.1.tgz#d92cc0af504d510e26a754a7dbc2e5c8cd9c7ab4" integrity sha512-VrsBByqAIntM+EYMqSm59SiMEf7qkmI9dqMt6RbD/wlwueWmYcI0FFK5Fj47pP6DRZm+3teXjosKlwcZJ5lIMw== dependencies: "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-typescript" "^7.12.1" "@babel/plugin-transform-unicode-escapes@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.1.tgz#5232b9f81ccb07070b7c3c36c67a1b78f1845709" integrity sha512-I8gNHJLIc7GdApm7wkVnStWssPNbSRMPtgHdmH3sRM1zopz09UWPS4x5V4n1yz/MIWTVnJ9sp6IkuXdWM4w+2Q== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-unicode-regex@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.1.tgz#cc9661f61390db5c65e3febaccefd5c6ac3faecb" integrity sha512-SqH4ClNngh/zGwHZOOQMTD+e8FGWexILV+ePMyiDJttAWRh5dhDL8rcl5lSgU3Huiq6Zn6pWTMvdPAb21Dwdyg== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" "@babel/polyfill@^7.4.4": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.12.1.tgz#1f2d6371d1261bbd961f3c5d5909150e12d0bd96" integrity sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g== dependencies: core-js "^2.6.5" regenerator-runtime "^0.13.4" "@babel/preset-env@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.12.11.tgz#55d5f7981487365c93dbbc84507b1c7215e857f9" integrity sha512-j8Tb+KKIXKYlDBQyIOy4BLxzv1NUOwlHfZ74rvW+Z0Gp4/cI2IMDPBWAgWceGcE7aep9oL/0K9mlzlMGxA8yNw== dependencies: "@babel/compat-data" "^7.12.7" "@babel/helper-compilation-targets" "^7.12.5" "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-validator-option" "^7.12.11" "@babel/plugin-proposal-async-generator-functions" "^7.12.1" "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-dynamic-import" "^7.12.1" "@babel/plugin-proposal-export-namespace-from" "^7.12.1" "@babel/plugin-proposal-json-strings" "^7.12.1" "@babel/plugin-proposal-logical-assignment-operators" "^7.12.1" "@babel/plugin-proposal-nullish-coalescing-operator" "^7.12.1" "@babel/plugin-proposal-numeric-separator" "^7.12.7" "@babel/plugin-proposal-object-rest-spread" "^7.12.1" "@babel/plugin-proposal-optional-catch-binding" "^7.12.1" "@babel/plugin-proposal-optional-chaining" "^7.12.7" "@babel/plugin-proposal-private-methods" "^7.12.1" "@babel/plugin-proposal-unicode-property-regex" "^7.12.1" "@babel/plugin-syntax-async-generators" "^7.8.0" "@babel/plugin-syntax-class-properties" "^7.12.1" "@babel/plugin-syntax-dynamic-import" "^7.8.0" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" "@babel/plugin-syntax-json-strings" "^7.8.0" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" "@babel/plugin-syntax-numeric-separator" "^7.10.4" "@babel/plugin-syntax-object-rest-spread" "^7.8.0" "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" "@babel/plugin-syntax-optional-chaining" "^7.8.0" "@babel/plugin-syntax-top-level-await" "^7.12.1" "@babel/plugin-transform-arrow-functions" "^7.12.1" "@babel/plugin-transform-async-to-generator" "^7.12.1" "@babel/plugin-transform-block-scoped-functions" "^7.12.1" "@babel/plugin-transform-block-scoping" "^7.12.11" "@babel/plugin-transform-classes" "^7.12.1" "@babel/plugin-transform-computed-properties" "^7.12.1" "@babel/plugin-transform-destructuring" "^7.12.1" "@babel/plugin-transform-dotall-regex" "^7.12.1" "@babel/plugin-transform-duplicate-keys" "^7.12.1" "@babel/plugin-transform-exponentiation-operator" "^7.12.1" "@babel/plugin-transform-for-of" "^7.12.1" "@babel/plugin-transform-function-name" "^7.12.1" "@babel/plugin-transform-literals" "^7.12.1" "@babel/plugin-transform-member-expression-literals" "^7.12.1" "@babel/plugin-transform-modules-amd" "^7.12.1" "@babel/plugin-transform-modules-commonjs" "^7.12.1" "@babel/plugin-transform-modules-systemjs" "^7.12.1" "@babel/plugin-transform-modules-umd" "^7.12.1" "@babel/plugin-transform-named-capturing-groups-regex" "^7.12.1" "@babel/plugin-transform-new-target" "^7.12.1" "@babel/plugin-transform-object-super" "^7.12.1" "@babel/plugin-transform-parameters" "^7.12.1" "@babel/plugin-transform-property-literals" "^7.12.1" "@babel/plugin-transform-regenerator" "^7.12.1" "@babel/plugin-transform-reserved-words" "^7.12.1" "@babel/plugin-transform-shorthand-properties" "^7.12.1" "@babel/plugin-transform-spread" "^7.12.1" "@babel/plugin-transform-sticky-regex" "^7.12.7" "@babel/plugin-transform-template-literals" "^7.12.1" "@babel/plugin-transform-typeof-symbol" "^7.12.10" "@babel/plugin-transform-unicode-escapes" "^7.12.1" "@babel/plugin-transform-unicode-regex" "^7.12.1" "@babel/preset-modules" "^0.1.3" "@babel/types" "^7.12.11" core-js-compat "^3.8.0" semver "^5.5.0" "@babel/preset-modules@^0.1.3": version "0.1.4" resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.4.tgz#362f2b68c662842970fdb5e254ffc8fc1c2e415e" integrity sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg== dependencies: "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-proposal-unicode-property-regex" "^7.4.4" "@babel/plugin-transform-dotall-regex" "^7.4.4" "@babel/types" "^7.4.4" esutils "^2.0.2" "@babel/preset-typescript@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.12.7.tgz#fc7df8199d6aae747896f1e6c61fc872056632a3" integrity sha512-nOoIqIqBmHBSEgBXWR4Dv/XBehtIFcw9PqZw6rFYuKrzsZmOQm3PR5siLBnKZFEsDb03IegG8nSjU/iXXXYRmw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-validator-option" "^7.12.1" "@babel/plugin-transform-typescript" "^7.12.1" "@babel/register@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.12.10.tgz#19b87143f17128af4dbe7af54c735663b3999f60" integrity sha512-EvX/BvMMJRAA3jZgILWgbsrHwBQvllC5T8B29McyME8DvkdOxk4ujESfrMvME8IHSDvWXrmMXxPvA/lx2gqPLQ== dependencies: find-cache-dir "^2.0.0" lodash "^4.17.19" make-dir "^2.1.0" pirates "^4.0.0" source-map-support "^0.5.16" "@babel/runtime@^7.12.5", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4": version "7.12.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== dependencies: regenerator-runtime "^0.13.4" "@babel/template@^7.10.4", "@babel/template@^7.12.7", "@babel/template@^7.3.3": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" integrity sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow== dependencies: "@babel/code-frame" "^7.10.4" "@babel/parser" "^7.12.7" "@babel/types" "^7.12.7" "@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.5", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.4": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376" integrity sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w== dependencies: "@babel/code-frame" "^7.12.11" "@babel/generator" "^7.12.11" "@babel/helper-function-name" "^7.12.11" "@babel/helper-split-export-declaration" "^7.12.11" "@babel/parser" "^7.12.11" "@babel/types" "^7.12.12" debug "^4.1.0" globals "^11.1.0" lodash "^4.17.19" "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" integrity sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ== dependencies: "@babel/helper-validator-identifier" "^7.12.11" lodash "^4.17.19" to-fast-properties "^2.0.0" "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== "@cnakazawa/watch@^1.0.3": version "1.0.4" resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a" integrity sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ== dependencies: exec-sh "^0.3.2" minimist "^1.2.0" "@eslint/eslintrc@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.3.0.tgz#d736d6963d7003b6514e6324bec9c602ac340318" integrity sha512-1JTKgrOKAHVivSvOYw+sJOunkBjUOvjqWk1DPja7ZFhIS2mX/4EgTT8M7eTK9jrKhL/FvXXEbQwIs3pg1xp3dg== dependencies: ajv "^6.12.4" debug "^4.1.1" espree "^7.3.0" globals "^12.1.0" ignore "^4.0.6" import-fresh "^3.2.1" js-yaml "^3.13.1" lodash "^4.17.20" minimatch "^3.0.4" strip-json-comments "^3.1.1" "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== dependencies: camelcase "^5.3.1" find-up "^4.1.0" get-package-type "^0.1.0" js-yaml "^3.13.1" resolve-from "^5.0.0" "@istanbuljs/schema@^0.1.2": version "0.1.2" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== "@jest/console@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/console/-/console-26.6.2.tgz#4e04bc464014358b03ab4937805ee36a0aeb98f2" integrity sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g== dependencies: "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" jest-message-util "^26.6.2" jest-util "^26.6.2" slash "^3.0.0" "@jest/core@^26.6.3": version "26.6.3" resolved "https://registry.yarnpkg.com/@jest/core/-/core-26.6.3.tgz#7639fcb3833d748a4656ada54bde193051e45fad" integrity sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw== dependencies: "@jest/console" "^26.6.2" "@jest/reporters" "^26.6.2" "@jest/test-result" "^26.6.2" "@jest/transform" "^26.6.2" "@jest/types" "^26.6.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" jest-changed-files "^26.6.2" jest-config "^26.6.3" jest-haste-map "^26.6.2" jest-message-util "^26.6.2" jest-regex-util "^26.0.0" jest-resolve "^26.6.2" jest-resolve-dependencies "^26.6.3" jest-runner "^26.6.3" jest-runtime "^26.6.3" jest-snapshot "^26.6.2" jest-util "^26.6.2" jest-validate "^26.6.2" jest-watcher "^26.6.2" micromatch "^4.0.2" p-each-series "^2.1.0" rimraf "^3.0.0" slash "^3.0.0" strip-ansi "^6.0.0" "@jest/environment@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-26.6.2.tgz#ba364cc72e221e79cc8f0a99555bf5d7577cf92c" integrity sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA== dependencies: "@jest/fake-timers" "^26.6.2" "@jest/types" "^26.6.2" "@types/node" "*" jest-mock "^26.6.2" "@jest/fake-timers@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-26.6.2.tgz#459c329bcf70cee4af4d7e3f3e67848123535aad" integrity sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA== dependencies: "@jest/types" "^26.6.2" "@sinonjs/fake-timers" "^6.0.1" "@types/node" "*" jest-message-util "^26.6.2" jest-mock "^26.6.2" jest-util "^26.6.2" "@jest/globals@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-26.6.2.tgz#5b613b78a1aa2655ae908eba638cc96a20df720a" integrity sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA== dependencies: "@jest/environment" "^26.6.2" "@jest/types" "^26.6.2" expect "^26.6.2" "@jest/reporters@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-26.6.2.tgz#1f518b99637a5f18307bd3ecf9275f6882a667f6" integrity sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw== dependencies: "@bcoe/v8-coverage" "^0.2.3" "@jest/console" "^26.6.2" "@jest/test-result" "^26.6.2" "@jest/transform" "^26.6.2" "@jest/types" "^26.6.2" chalk "^4.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" glob "^7.1.2" graceful-fs "^4.2.4" istanbul-lib-coverage "^3.0.0" istanbul-lib-instrument "^4.0.3" istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" istanbul-reports "^3.0.2" jest-haste-map "^26.6.2" jest-resolve "^26.6.2" jest-util "^26.6.2" jest-worker "^26.6.2" slash "^3.0.0" source-map "^0.6.0" string-length "^4.0.1" terminal-link "^2.0.0" v8-to-istanbul "^7.0.0" optionalDependencies: node-notifier "^8.0.0" "@jest/source-map@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-26.6.2.tgz#29af5e1e2e324cafccc936f218309f54ab69d535" integrity sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA== dependencies: callsites "^3.0.0" graceful-fs "^4.2.4" source-map "^0.6.0" "@jest/test-result@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-26.6.2.tgz#55da58b62df134576cc95476efa5f7949e3f5f18" integrity sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ== dependencies: "@jest/console" "^26.6.2" "@jest/types" "^26.6.2" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" "@jest/test-sequencer@^26.6.3": version "26.6.3" resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-26.6.3.tgz#98e8a45100863886d074205e8ffdc5a7eb582b17" integrity sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw== dependencies: "@jest/test-result" "^26.6.2" graceful-fs "^4.2.4" jest-haste-map "^26.6.2" jest-runner "^26.6.3" jest-runtime "^26.6.3" "@jest/transform@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-26.6.2.tgz#5ac57c5fa1ad17b2aae83e73e45813894dcf2e4b" integrity sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA== dependencies: "@babel/core" "^7.1.0" "@jest/types" "^26.6.2" babel-plugin-istanbul "^6.0.0" chalk "^4.0.0" convert-source-map "^1.4.0" fast-json-stable-stringify "^2.0.0" graceful-fs "^4.2.4" jest-haste-map "^26.6.2" jest-regex-util "^26.0.0" jest-util "^26.6.2" micromatch "^4.0.2" pirates "^4.0.1" slash "^3.0.0" source-map "^0.6.1" write-file-atomic "^3.0.0" "@jest/types@^26.6.2": version "26.6.2" resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== dependencies: "@types/istanbul-lib-coverage" "^2.0.0" "@types/istanbul-reports" "^3.0.0" "@types/node" "*" "@types/yargs" "^15.0.0" chalk "^4.0.0" "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents": version "2.1.8-no-fsevents" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.tgz#da7c3996b8e6e19ebd14d82eaced2313e7769f9b" integrity sha512-+nb9vWloHNNMFHjGofEam3wopE3m1yuambrrd/fnPc+lFOMB9ROTqQlche9ByFWNkdNqfSgR/kkQtQ8DzEWt2w== dependencies: anymatch "^2.0.0" async-each "^1.0.1" braces "^2.3.2" glob-parent "^3.1.0" inherits "^2.0.3" is-binary-path "^1.0.0" is-glob "^4.0.0" normalize-path "^3.0.0" path-is-absolute "^1.0.0" readdirp "^2.2.1" upath "^1.1.1" "@nodelib/fs.scandir@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz#d4b3549a5db5de2683e0c1071ab4f140904bbf69" integrity sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA== dependencies: "@nodelib/fs.stat" "2.0.4" run-parallel "^1.1.9" "@nodelib/fs.stat@2.0.4", "@nodelib/fs.stat@^2.0.2": version "2.0.4" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz#a3f2dd61bab43b8db8fa108a121cfffe4c676655" integrity sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q== "@nodelib/fs.walk@^1.2.3": version "1.2.6" resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz#cce9396b30aa5afe9e3756608f5831adcb53d063" integrity sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow== dependencies: "@nodelib/fs.scandir" "2.1.4" fastq "^1.6.0" "@sinonjs/commons@^1.7.0": version "1.8.2" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.2.tgz#858f5c4b48d80778fde4b9d541f27edc0d56488b" integrity sha512-sruwd86RJHdsVf/AtBoijDmUqJp3B6hF/DGC23C+JaegnDHaZyewCjoVGTdg3J0uz3Zs7NnIT05OBOmML72lQw== dependencies: type-detect "4.0.8" "@sinonjs/fake-timers@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40" integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA== dependencies: "@sinonjs/commons" "^1.7.0" "@types/babel-types@*", "@types/babel-types@^7.0.0": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.9.tgz#01d7b86949f455402a94c788883fe4ba574cad41" integrity sha512-qZLoYeXSTgQuK1h7QQS16hqLGdmqtRmN8w/rl3Au/l5x/zkHx+a4VHrHyBsi1I1vtK2oBHxSzKIu0R5p6spdOA== "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.1.12" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d" integrity sha512-wMTHiiTiBAAPebqaPiPDLFA4LYPKr6Ph0Xq/6rq1Ur3v66HXyG+clfR9CNETkD7MQS8ZHvpQOtA53DLws5WAEQ== dependencies: "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" "@types/babel__generator" "*" "@types/babel__template" "*" "@types/babel__traverse" "*" "@types/babel__generator@*": version "7.6.2" resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.2.tgz#f3d71178e187858f7c45e30380f8f1b7415a12d8" integrity sha512-MdSJnBjl+bdwkLskZ3NGFp9YcXGx5ggLpQQPqtgakVhsWK0hTtNYhjpZLlWQTviGTvF8at+Bvli3jV7faPdgeQ== dependencies: "@babel/types" "^7.0.0" "@types/babel__template@*": version "7.4.0" resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.0.tgz#0c888dd70b3ee9eebb6e4f200e809da0076262be" integrity sha512-NTPErx4/FiPCGScH7foPyr+/1Dkzkni+rHiYHHoTjvwou7AQzJkNeD60A9CXRy+ZEN2B1bggmkTMCDb+Mv5k+A== dependencies: "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" "@types/babel__traverse@*", "@types/babel__traverse@^7.0.4", "@types/babel__traverse@^7.0.6": version "7.11.0" resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.11.0.tgz#b9a1efa635201ba9bc850323a8793ee2d36c04a0" integrity sha512-kSjgDMZONiIfSH1Nxcr5JIRMwUetDki63FSQfpTCz8ogF3Ulqm8+mr5f78dUYs6vMiB6gBusQqfQmBvHZj/lwg== dependencies: "@babel/types" "^7.3.0" "@types/babylon@^6.16.2": version "6.16.5" resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.5.tgz#1c5641db69eb8cdf378edd25b4be7754beeb48b4" integrity sha512-xH2e58elpj1X4ynnKp9qSnWlsRTIs6n3tgLGNfwAGHwePw0mulHQllV34n0T25uYSu1k0hRKkWXF890B1yS47w== dependencies: "@types/babel-types" "*" "@types/caseless@*": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== "@types/graceful-fs@^4.1.2": version "4.1.4" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.4.tgz#4ff9f641a7c6d1a3508ff88bc3141b152772e753" integrity sha512-mWA/4zFQhfvOA8zWkXobwJvBD7vzcxgrOQ0J5CH1votGqdq9m7+FwtGaqyCZqC3NyyBkc9z4m+iry4LlqcMWJg== dependencies: "@types/node" "*" "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" integrity sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw== "@types/istanbul-lib-report@*": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== dependencies: "@types/istanbul-lib-coverage" "*" "@types/istanbul-reports@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz#508b13aa344fa4976234e75dddcc34925737d821" integrity sha512-nwKNbvnwJ2/mndE9ItP/zc2TCzw6uuodnF4EHYWD+gCQDVBuRQL5UzbZD0/ezy1iKsFU2ZQiDqg4M9dN4+wZgA== dependencies: "@types/istanbul-lib-report" "*" "@types/jest@^26.0.20": version "26.0.20" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.20.tgz#cd2f2702ecf69e86b586e1f5223a60e454056307" integrity sha512-9zi2Y+5USJRxd0FsahERhBwlcvFh6D2GLQnY2FH2BzK8J9s9omvNHIbvABwIluXa0fD8XVKMLTO0aOEuUfACAA== dependencies: jest-diff "^26.0.0" pretty-format "^26.0.0" "@types/json-schema@^7.0.3": version "7.0.7" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= "@types/node@*": version "14.14.22" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.22.tgz#0d29f382472c4ccf3bd96ff0ce47daf5b7b84b18" integrity sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw== "@types/node@12": version "12.19.14" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.19.14.tgz#59e5029a3c2aea34f68b717955381692fd47cafb" integrity sha512-2U9uLN46+7dv9PiS8VQJcHhuoOjiDPZOLAt0WuA1EanEknIMae+2QbMhayF7cgGqjvRVIfNpt+6jLPczJZFiRw== "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== "@types/prettier@^2.0.0": version "2.1.6" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.6.tgz#f4b1efa784e8db479cdb8b14403e2144b1e9ff03" integrity sha512-6gOkRe7OIioWAXfnO/2lFiv+SJichKVSys1mSsgyrYHSEjk8Ctv4tSR/Odvnu+HWlH2C8j53dahU03XmQdd5fA== "@types/request@^2.48.5": version "2.48.5" resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.5.tgz#019b8536b402069f6d11bee1b2c03e7f232937a0" integrity sha512-/LO7xRVnL3DxJ1WkPGDQrp4VTV1reX9RkC85mJ+Qzykj2Bdw+mG15aAfDahc76HtknjzE16SX/Yddn6MxVbmGQ== dependencies: "@types/caseless" "*" "@types/node" "*" "@types/tough-cookie" "*" form-data "^2.5.0" "@types/stack-utils@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw== "@types/tough-cookie@*": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d" integrity sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A== "@types/yargs-parser@*": version "20.2.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9" integrity sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA== "@types/yargs@^15.0.0": version "15.0.12" resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.12.tgz#6234ce3e3e3fa32c5db301a170f96a599c960d74" integrity sha512-f+fD/fQAo3BCbCDlrUpznF1A5Zp9rB0noS5vnoormHSIPFKL0Z2DcUJ3Gxp5ytH4uLRNxy7AwYUC9exZzqGMAw== dependencies: "@types/yargs-parser" "*" "@typescript-eslint/eslint-plugin@^4.14.0": version "4.14.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.14.0.tgz#92db8e7c357ed7d69632d6843ca70b71be3a721d" integrity sha512-IJ5e2W7uFNfg4qh9eHkHRUCbgZ8VKtGwD07kannJvM5t/GU8P8+24NX8gi3Hf5jST5oWPY8kyV1s/WtfiZ4+Ww== dependencies: "@typescript-eslint/experimental-utils" "4.14.0" "@typescript-eslint/scope-manager" "4.14.0" debug "^4.1.1" functional-red-black-tree "^1.0.1" lodash "^4.17.15" regexpp "^3.0.0" semver "^7.3.2" tsutils "^3.17.1" "@typescript-eslint/experimental-utils@4.14.0": version "4.14.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.14.0.tgz#5aa7b006736634f588a69ee343ca959cd09988df" integrity sha512-6i6eAoiPlXMKRbXzvoQD5Yn9L7k9ezzGRvzC/x1V3650rUk3c3AOjQyGYyF9BDxQQDK2ElmKOZRD0CbtdkMzQQ== dependencies: "@types/json-schema" "^7.0.3" "@typescript-eslint/scope-manager" "4.14.0" "@typescript-eslint/types" "4.14.0" "@typescript-eslint/typescript-estree" "4.14.0" eslint-scope "^5.0.0" eslint-utils "^2.0.0" "@typescript-eslint/parser@^4.14.0": version "4.14.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.14.0.tgz#62d4cd2079d5c06683e9bfb200c758f292c4dee7" integrity sha512-sUDeuCjBU+ZF3Lzw0hphTyScmDDJ5QVkyE21pRoBo8iDl7WBtVFS+WDN3blY1CH3SBt7EmYCw6wfmJjF0l/uYg== dependencies: "@typescript-eslint/scope-manager" "4.14.0" "@typescript-eslint/types" "4.14.0" "@typescript-eslint/typescript-estree" "4.14.0" debug "^4.1.1" "@typescript-eslint/scope-manager@4.14.0": version "4.14.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.14.0.tgz#55a4743095d684e1f7b7180c4bac2a0a3727f517" integrity sha512-/J+LlRMdbPh4RdL4hfP1eCwHN5bAhFAGOTsvE6SxsrM/47XQiPSgF5MDgLyp/i9kbZV9Lx80DW0OpPkzL+uf8Q== dependencies: "@typescript-eslint/types" "4.14.0" "@typescript-eslint/visitor-keys" "4.14.0" "@typescript-eslint/types@4.14.0": version "4.14.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.14.0.tgz#d8a8202d9b58831d6fd9cee2ba12f8a5a5dd44b6" integrity sha512-VsQE4VvpldHrTFuVPY1ZnHn/Txw6cZGjL48e+iBxTi2ksa9DmebKjAeFmTVAYoSkTk7gjA7UqJ7pIsyifTsI4A== "@typescript-eslint/typescript-estree@4.14.0": version "4.14.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.14.0.tgz#4bcd67486e9acafc3d0c982b23a9ab8ac8911ed7" integrity sha512-wRjZ5qLao+bvS2F7pX4qi2oLcOONIB+ru8RGBieDptq/SudYwshveORwCVU4/yMAd4GK7Fsf8Uq1tjV838erag== dependencies: "@typescript-eslint/types" "4.14.0" "@typescript-eslint/visitor-keys" "4.14.0" debug "^4.1.1" globby "^11.0.1" is-glob "^4.0.1" lodash "^4.17.15" semver "^7.3.2" tsutils "^3.17.1" "@typescript-eslint/visitor-keys@4.14.0": version "4.14.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.14.0.tgz#b1090d9d2955b044b2ea2904a22496849acbdf54" integrity sha512-MeHHzUyRI50DuiPgV9+LxcM52FCJFYjJiWHtXlbyC27b80mfOwKeiKI+MHOTEpcpfmoPFm/vvQS88bYIx6PZTA== dependencies: "@typescript-eslint/types" "4.14.0" eslint-visitor-keys "^2.0.0" JSONStream@^1.0.3: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ== dependencies: jsonparse "^1.2.0" through ">=2.2.7 <3" abab@^2.0.3: version "2.0.5" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== acorn-globals@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-3.1.0.tgz#fd8270f71fbb4996b004fa880ee5d46573a731bf" integrity sha1-/YJw9x+7SZawBPqIDuXUZXOnMb8= dependencies: acorn "^4.0.4" acorn-globals@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45" integrity sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg== dependencies: acorn "^7.1.1" acorn-walk "^7.1.1" acorn-jsx@^5.2.0, acorn-jsx@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b" integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng== acorn-node@^1.2.0, acorn-node@^1.3.0, acorn-node@^1.5.2, acorn-node@^1.6.1: version "1.8.2" resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8" integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A== dependencies: acorn "^7.0.0" acorn-walk "^7.0.0" xtend "^4.0.2" acorn-walk@^7.0.0, acorn-walk@^7.1.1: version "7.2.0" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== acorn@^3.1.0: version "3.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" integrity sha1-ReN/s56No/JbruP/U2niu18iAXo= acorn@^4.0.4, acorn@~4.0.2: version "4.0.13" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" integrity sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c= acorn@^7.0.0, acorn@^7.1.1, acorn@^7.4.0: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" json-schema-traverse "^0.4.1" uri-js "^4.2.2" ajv@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/ajv/-/ajv-7.0.3.tgz#13ae747eff125cafb230ac504b2406cf371eece2" integrity sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ== dependencies: fast-deep-equal "^3.1.1" json-schema-traverse "^1.0.0" require-from-string "^2.0.2" uri-js "^4.2.2" align-text@^0.1.1, align-text@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" integrity sha1-DNkKVhCT810KmSVsIrcGlDP60Rc= dependencies: kind-of "^3.0.2" longest "^1.0.1" repeat-string "^1.5.2" another-json@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/another-json/-/another-json-0.2.0.tgz#b5f4019c973b6dd5c6506a2d93469cb6d32aeedc" integrity sha1-tfQBnJc7bdXGUGotk0acttMq7tw= ansi-colors@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== ansi-escapes@^4.2.1: version "4.3.1" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.1.tgz#a5c47cc43181f1f38ffd7076837700d395522a61" integrity sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA== dependencies: type-fest "^0.11.0" ansi-regex@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== ansi-regex@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== ansi-styles@^3.2.0, ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== dependencies: color-convert "^1.9.0" ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: color-convert "^2.0.1" any-promise@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= anymatch@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== dependencies: micromatch "^3.1.4" normalize-path "^2.1.1" anymatch@^3.0.3, anymatch@~3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== dependencies: normalize-path "^3.0.0" picomatch "^2.0.4" argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== dependencies: sprintf-js "~1.0.2" arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= arr-flatten@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== arr-union@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= array-filter@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-1.0.0.tgz#baf79e62e6ef4c2a4c0b831232daffec251f9d83" integrity sha1-uveeYubvTCpMC4MSMtr/7CUfnYM= array-includes@^3.1.1, array-includes@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.2.tgz#a8db03e0b88c8c6aeddc49cb132f9bcab4ebf9c8" integrity sha512-w2GspexNQpx+PutG3QpT437/BenZBj0M/MZGn5mzv/MofYqo0xmRHzn4lFsoDlWJ+THYsGJmFlW68WlDFx7VRw== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" es-abstract "^1.18.0-next.1" get-intrinsic "^1.0.1" is-string "^1.0.5" array-union@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== array-unique@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= array.prototype.flat@^1.2.3: version "1.2.4" resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123" integrity sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" es-abstract "^1.18.0-next.1" array.prototype.flatmap@^1.2.3: version "1.2.4" resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.2.4.tgz#94cfd47cc1556ec0747d97f7c7738c58122004c9" integrity sha512-r9Z0zYoxqHz60vvQbWEdXIEtCwHF0yxaWfno9qzXeNHvfyl3BZqygmGzb84dsubyaXLH4husF+NFgMSdpZhk2Q== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" es-abstract "^1.18.0-next.1" function-bind "^1.1.1" asap@~2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= asn1.js@^5.2.0: version "5.4.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== dependencies: bn.js "^4.0.0" inherits "^2.0.1" minimalistic-assert "^1.0.0" safer-buffer "^2.1.0" asn1@~0.2.3: version "0.2.4" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== dependencies: safer-buffer "~2.1.0" assert-plus@1.0.0, assert-plus@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= assert@^1.4.0: version "1.5.0" resolved "https://registry.yarnpkg.com/assert/-/assert-1.5.0.tgz#55c109aaf6e0aefdb3dc4b71240c70bf574b18eb" integrity sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA== dependencies: object-assign "^4.1.1" util "0.10.3" assign-symbols@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= ast-types@0.12.4, ast-types@^0.12.2: version "0.12.4" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.12.4.tgz#71ce6383800f24efc9a1a3308f3a6e420a0974d1" integrity sha512-ky/YVYCbtVAS8TdMIaTiPFHwEpRB5z1hctepJplTr3UW5q8TDrpIMCILyk8pmLxGtn2KCtC/lSn7zOsaI7nzDw== ast-types@^0.14.2: version "0.14.2" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.14.2.tgz#600b882df8583e3cd4f2df5fa20fa83759d4bdfd" integrity sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA== dependencies: tslib "^2.0.1" astral-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== async-each@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= atob@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== available-typed-arrays@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz#6b098ca9d8039079ee3f77f7b783c4480ba513f5" integrity sha512-XWX3OX8Onv97LMk/ftVyBibpGwY5a8SmuxZPzeOxqmuEqUCOM9ZE+uIaD1VNJ5QnvU2UQusvmKbuM1FR8QWGfQ== dependencies: array-filter "^1.0.0" aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= aws4@^1.8.0: version "1.11.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== babel-eslint@^10.0.1, babel-eslint@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232" integrity sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg== dependencies: "@babel/code-frame" "^7.0.0" "@babel/parser" "^7.7.0" "@babel/traverse" "^7.7.0" "@babel/types" "^7.7.0" eslint-visitor-keys "^1.0.0" resolve "^1.12.0" babel-jest@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.6.3.tgz#d87d25cb0037577a0c89f82e5755c5d293c01056" integrity sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA== dependencies: "@jest/transform" "^26.6.2" "@jest/types" "^26.6.2" "@types/babel__core" "^7.1.7" babel-plugin-istanbul "^6.0.0" babel-preset-jest "^26.6.2" chalk "^4.0.0" graceful-fs "^4.2.4" slash "^3.0.0" babel-plugin-dynamic-import-node@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3" integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ== dependencies: object.assign "^4.1.0" babel-plugin-istanbul@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz#e159ccdc9af95e0b570c75b4573b7c34d671d765" integrity sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ== dependencies: "@babel/helper-plugin-utils" "^7.0.0" "@istanbuljs/load-nyc-config" "^1.0.0" "@istanbuljs/schema" "^0.1.2" istanbul-lib-instrument "^4.0.0" test-exclude "^6.0.0" babel-plugin-jest-hoist@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.6.2.tgz#8185bd030348d254c6d7dd974355e6a28b21e62d" integrity sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw== dependencies: "@babel/template" "^7.3.3" "@babel/types" "^7.3.3" "@types/babel__core" "^7.0.0" "@types/babel__traverse" "^7.0.6" babel-preset-current-node-syntax@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" integrity sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ== dependencies: "@babel/plugin-syntax-async-generators" "^7.8.4" "@babel/plugin-syntax-bigint" "^7.8.3" "@babel/plugin-syntax-class-properties" "^7.8.3" "@babel/plugin-syntax-import-meta" "^7.8.3" "@babel/plugin-syntax-json-strings" "^7.8.3" "@babel/plugin-syntax-logical-assignment-operators" "^7.8.3" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" "@babel/plugin-syntax-numeric-separator" "^7.8.3" "@babel/plugin-syntax-object-rest-spread" "^7.8.3" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" "@babel/plugin-syntax-optional-chaining" "^7.8.3" "@babel/plugin-syntax-top-level-await" "^7.8.3" babel-preset-jest@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-26.6.2.tgz#747872b1171df032252426586881d62d31798fee" integrity sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ== dependencies: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" babel-runtime@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= dependencies: core-js "^2.4.0" regenerator-runtime "^0.11.0" babel-types@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc= dependencies: babel-runtime "^6.26.0" esutils "^2.0.2" lodash "^4.17.4" to-fast-properties "^1.0.3" babelify@^10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/babelify/-/babelify-10.0.0.tgz#fe73b1a22583f06680d8d072e25a1e0d1d1d7fb5" integrity sha512-X40FaxyH7t3X+JFAKvb1H9wooWKLRCi8pg3m8poqtdZaIng+bjzp9RvKQCvRjF9isHiPkXspbbXT/zwXLtwgwg== babylon@^6.18.0: version "6.18.0" resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ== balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= base-x@^3.0.2: version "3.0.8" resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.8.tgz#1e1106c2537f0162e8b52474a557ebb09000018d" integrity sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA== dependencies: safe-buffer "^5.0.1" base64-arraybuffer-es6@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.6.0.tgz#036f79f57588dca0018de7792ddf149299382007" integrity sha512-57nLqKj4ShsDwFJWJsM4sZx6u60WbCge35rWRSevUwqxDtRwwxiKAO800zD2upPv4CfdWjQp//wSLar35nDKvA== base64-js@^1.0.2: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== base@^0.11.1: version "0.11.2" resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== dependencies: cache-base "^1.0.1" class-utils "^0.3.5" component-emitter "^1.2.1" define-property "^1.0.0" isobject "^3.0.1" mixin-deep "^1.2.0" pascalcase "^0.1.1" bcrypt-pbkdf@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= dependencies: tweetnacl "^0.14.3" better-docs@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/better-docs/-/better-docs-2.3.2.tgz#0de059301c49669a4350409d8c235868cf8bcbf7" integrity sha512-VlbXQgEftaynJSaPa853XB5WqTlPoQQr2TnxIkKi6OsyJJxF42Ke+9SIES/hqTe58aaBnuoDGrIzOso8RdNx6Q== dependencies: brace "^0.11.1" react-ace "^6.5.0" react-docgen "^5.3.0" react-frame-component "^4.1.1" typescript "^3.7.5" underscore "^1.9.1" vue-docgen-api "^3.22.0" vue2-ace-editor "^0.0.13" binary-extensions@^1.0.0: version "1.13.1" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== bluebird@^3.5.0, bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: version "4.12.0" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== bn.js@^5.0.0, bn.js@^5.1.1: version "5.1.3" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b" integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ== brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace@^0.11.0, brace@^0.11.1: version "0.11.1" resolved "https://registry.yarnpkg.com/brace/-/brace-0.11.1.tgz#4896fcc9d544eef45f4bb7660db320d3b379fe58" integrity sha1-SJb8ydVE7vRfS7dmDbMg07N5/lg= braces@^2.3.1, braces@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== dependencies: arr-flatten "^1.1.0" array-unique "^0.3.2" extend-shallow "^2.0.1" fill-range "^4.0.0" isobject "^3.0.1" repeat-element "^1.1.2" snapdragon "^0.8.1" snapdragon-node "^2.0.1" split-string "^3.0.2" to-regex "^3.0.1" braces@^3.0.1, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== dependencies: fill-range "^7.0.1" brorand@^1.0.1, brorand@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= browser-pack@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/browser-pack/-/browser-pack-6.1.0.tgz#c34ba10d0b9ce162b5af227c7131c92c2ecd5774" integrity sha512-erYug8XoqzU3IfcU8fUgyHqyOXqIE4tUTTQ+7mqUjQlvnXkOO6OlT9c/ZoJVHYoAaqGxr09CN53G7XIsO4KtWA== dependencies: JSONStream "^1.0.3" combine-source-map "~0.8.0" defined "^1.0.0" safe-buffer "^5.1.1" through2 "^2.0.0" umd "^3.0.0" browser-process-hrtime@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== browser-request@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/browser-request/-/browser-request-0.3.3.tgz#9ece5b5aca89a29932242e18bf933def9876cc17" integrity sha1-ns5bWsqJopkyJC4Yv5M975h2zBc= browser-resolve@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-2.0.0.tgz#99b7304cb392f8d73dba741bb2d7da28c6d7842b" integrity sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ== dependencies: resolve "^1.17.0" browserify-aes@^1.0.0, browserify-aes@^1.0.4: version "1.2.0" resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== dependencies: buffer-xor "^1.0.3" cipher-base "^1.0.0" create-hash "^1.1.0" evp_bytestokey "^1.0.3" inherits "^2.0.1" safe-buffer "^5.0.1" browserify-cipher@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" integrity sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== dependencies: browserify-aes "^1.0.4" browserify-des "^1.0.0" evp_bytestokey "^1.0.0" browserify-des@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.2.tgz#3af4f1f59839403572f1c66204375f7a7f703e9c" integrity sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A== dependencies: cipher-base "^1.0.1" des.js "^1.0.0" inherits "^2.0.1" safe-buffer "^5.1.2" browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: version "4.1.0" resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.0.tgz#b2fd06b5b75ae297f7ce2dc651f918f5be158c8d" integrity sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog== dependencies: bn.js "^5.0.0" randombytes "^2.0.1" browserify-sign@^4.0.0: version "4.2.1" resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.1.tgz#eaf4add46dd54be3bb3b36c0cf15abbeba7956c3" integrity sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg== dependencies: bn.js "^5.1.1" browserify-rsa "^4.0.1" create-hash "^1.2.0" create-hmac "^1.1.7" elliptic "^6.5.3" inherits "^2.0.4" parse-asn1 "^5.1.5" readable-stream "^3.6.0" safe-buffer "^5.2.0" browserify-zlib@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== dependencies: pako "~1.0.5" browserify@^17.0.0: version "17.0.0" resolved "https://registry.yarnpkg.com/browserify/-/browserify-17.0.0.tgz#4c48fed6c02bfa2b51fd3b670fddb805723cdc22" integrity sha512-SaHqzhku9v/j6XsQMRxPyBrSP3gnwmE27gLJYZgMT2GeK3J0+0toN+MnuNYDfHwVGQfLiMZ7KSNSIXHemy905w== dependencies: JSONStream "^1.0.3" assert "^1.4.0" browser-pack "^6.0.1" browser-resolve "^2.0.0" browserify-zlib "~0.2.0" buffer "~5.2.1" cached-path-relative "^1.0.0" concat-stream "^1.6.0" console-browserify "^1.1.0" constants-browserify "~1.0.0" crypto-browserify "^3.0.0" defined "^1.0.0" deps-sort "^2.0.1" domain-browser "^1.2.0" duplexer2 "~0.1.2" events "^3.0.0" glob "^7.1.0" has "^1.0.0" htmlescape "^1.1.0" https-browserify "^1.0.0" inherits "~2.0.1" insert-module-globals "^7.2.1" labeled-stream-splicer "^2.0.0" mkdirp-classic "^0.5.2" module-deps "^6.2.3" os-browserify "~0.3.0" parents "^1.0.1" path-browserify "^1.0.0" process "~0.11.0" punycode "^1.3.2" querystring-es3 "~0.2.0" read-only-stream "^2.0.0" readable-stream "^2.0.2" resolve "^1.1.4" shasum-object "^1.0.0" shell-quote "^1.6.1" stream-browserify "^3.0.0" stream-http "^3.0.0" string_decoder "^1.1.1" subarg "^1.0.0" syntax-error "^1.1.1" through2 "^2.0.0" timers-browserify "^1.0.1" tty-browserify "0.0.1" url "~0.11.0" util "~0.12.0" vm-browserify "^1.0.0" xtend "^4.0.0" browserslist@^4.14.5, browserslist@^4.16.1: version "4.16.1" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.1.tgz#bf757a2da376b3447b800a16f0f1c96358138766" integrity sha512-UXhDrwqsNcpTYJBTZsbGATDxZbiVDsx6UjpmRUmtnP10pr8wAYr5LgFoEFw9ixriQH2mv/NX2SfGzE/o8GndLA== dependencies: caniuse-lite "^1.0.30001173" colorette "^1.2.1" electron-to-chromium "^1.3.634" escalade "^3.1.1" node-releases "^1.1.69" bs58@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" integrity sha1-vhYedsNU9veIrkBx9j806MTwpCo= dependencies: base-x "^3.0.2" bser@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== dependencies: node-int64 "^0.4.0" buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== buffer-xor@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= buffer@~5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.2.1.tgz#dd57fa0f109ac59c602479044dca7b8b3d0b71d6" integrity sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg== dependencies: base64-js "^1.0.2" ieee754 "^1.1.4" builtin-status-codes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== dependencies: collection-visit "^1.0.0" component-emitter "^1.2.1" get-value "^2.0.6" has-value "^1.0.0" isobject "^3.0.1" set-value "^2.0.0" to-object-path "^0.3.0" union-value "^1.0.0" unset-value "^1.0.0" cached-path-relative@^1.0.0, cached-path-relative@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.2.tgz#a13df4196d26776220cc3356eb147a52dba2c6db" integrity sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg== call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== dependencies: function-bind "^1.1.1" get-intrinsic "^1.0.2" callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== camelcase@^1.0.2: version "1.2.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" integrity sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk= camelcase@^5.0.0, camelcase@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== camelcase@^6.0.0: version "6.2.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== caniuse-lite@^1.0.30001173: version "1.0.30001178" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001178.tgz#3ad813b2b2c7d585b0be0a2440e1e233c6eabdbc" integrity sha512-VtdZLC0vsXykKni8Uztx45xynytOi71Ufx9T8kHptSw9AL4dpqailUJJHavttuzUe1KYuBYtChiWv+BAb7mPmQ== capture-exit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" integrity sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g== dependencies: rsvp "^4.8.4" caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= catharsis@^0.8.11: version "0.8.11" resolved "https://registry.yarnpkg.com/catharsis/-/catharsis-0.8.11.tgz#d0eb3d2b82b7da7a3ce2efb1a7b00becc6643468" integrity sha512-a+xUyMV7hD1BrDQA/3iPV7oc+6W26BgVJO05PGEoatMyIuPScQKsde6i3YorWX1qs+AZjnJ18NqdKoCtKiNh1g== dependencies: lodash "^4.17.14" center-align@^0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" integrity sha1-qg0yYptu6XIgBBHL1EYckHvCt60= dependencies: align-text "^0.1.3" lazy-cache "^1.0.3" chalk@^2.0.0, chalk@^2.1.0: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== dependencies: ansi-styles "^3.2.1" escape-string-regexp "^1.0.5" supports-color "^5.3.0" chalk@^4.0.0, chalk@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== dependencies: ansi-styles "^4.1.0" supports-color "^7.1.0" char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== character-parser@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/character-parser/-/character-parser-2.2.0.tgz#c7ce28f36d4bcd9744e5ffc2c5fcde1c73261fc0" integrity sha1-x84o821LzZdE5f/CxfzeHHMmH8A= dependencies: is-regex "^1.0.3" chardet@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== chokidar@^3.4.0: version "3.5.1" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== dependencies: anymatch "~3.1.1" braces "~3.0.2" glob-parent "~5.1.0" is-binary-path "~2.1.0" is-glob "~4.0.1" normalize-path "~3.0.0" readdirp "~3.5.0" optionalDependencies: fsevents "~2.3.1" ci-info@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== dependencies: inherits "^2.0.1" safe-buffer "^5.0.1" cjs-module-lexer@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz#4186fcca0eae175970aee870b9fe2d6cf8d5655f" integrity sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw== class-utils@^0.3.5: version "0.3.6" resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== dependencies: arr-union "^3.1.0" define-property "^0.2.5" isobject "^3.0.0" static-extend "^0.1.1" clean-css@^4.1.11: version "4.2.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78" integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA== dependencies: source-map "~0.6.0" cli-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== dependencies: restore-cursor "^3.1.0" cli-width@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== cliui@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" integrity sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE= dependencies: center-align "^0.1.1" right-align "^0.1.1" wordwrap "0.0.2" cliui@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== dependencies: string-width "^4.2.0" strip-ansi "^6.0.0" wrap-ansi "^6.2.0" co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= collect-v8-coverage@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" integrity sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg== collection-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= dependencies: map-visit "^1.0.0" object-visit "^1.0.0" color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== dependencies: color-name "1.1.3" color-convert@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== dependencies: color-name "~1.1.4" color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== colorette@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw== combine-source-map@^0.8.0, combine-source-map@~0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/combine-source-map/-/combine-source-map-0.8.0.tgz#a58d0df042c186fcf822a8e8015f5450d2d79a8b" integrity sha1-pY0N8ELBhvz4IqjoAV9UUNLXmos= dependencies: convert-source-map "~1.1.0" inline-source-map "~0.6.0" lodash.memoize "~3.0.3" source-map "~0.5.3" combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== dependencies: delayed-stream "~1.0.0" commander@^2.19.0, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== commander@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= concat-stream@^1.6.0, concat-stream@^1.6.1, concat-stream@~1.6.0: version "1.6.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== dependencies: buffer-from "^1.0.0" inherits "^2.0.3" readable-stream "^2.2.2" typedarray "^0.0.6" console-browserify@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== constantinople@^3.0.1, constantinople@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/constantinople/-/constantinople-3.1.2.tgz#d45ed724f57d3d10500017a7d3a889c1381ae647" integrity sha512-yePcBqEFhLOqSBtwYOGGS1exHo/s1xjekXiinh4itpNQGCu4KA1euPh1fg07N2wMITZXQkBz75Ntdt1ctGZouw== dependencies: "@types/babel-types" "^7.0.0" "@types/babylon" "^6.16.2" babel-types "^6.26.0" babylon "^6.18.0" constants-browserify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= contains-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= content-type@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== dependencies: safe-buffer "~5.1.1" convert-source-map@~1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.1.3.tgz#4829c877e9fe49b3161f3bf3673888e204699860" integrity sha1-SCnId+n+SbMWHzvzZziI4gRpmGA= copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= core-js-compat@^3.8.0: version "3.8.3" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.8.3.tgz#9123fb6b9cad30f0651332dc77deba48ef9b0b3f" integrity sha512-1sCb0wBXnBIL16pfFG1Gkvei6UzvKyTNYpiC41yrdjEv0UoJoq9E/abTMzyYJ6JpTkAj15dLjbqifIzEBDVvog== dependencies: browserslist "^4.16.1" semver "7.0.0" core-js@^2.4.0, core-js@^2.5.3, core-js@^2.6.5: version "2.6.12" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= create-ecdh@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" integrity sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A== dependencies: bn.js "^4.1.0" elliptic "^6.5.3" create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== dependencies: cipher-base "^1.0.1" inherits "^2.0.1" md5.js "^1.3.4" ripemd160 "^2.0.1" sha.js "^2.4.0" create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== dependencies: cipher-base "^1.0.3" create-hash "^1.1.0" inherits "^2.0.1" ripemd160 "^2.0.0" safe-buffer "^5.0.1" sha.js "^2.4.8" cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== dependencies: nice-try "^1.0.4" path-key "^2.0.1" semver "^5.5.0" shebang-command "^1.2.0" which "^1.2.9" cross-spawn@^7.0.0, cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" which "^2.0.1" crypto-browserify@^3.0.0: version "3.12.0" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" integrity sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg== dependencies: browserify-cipher "^1.0.0" browserify-sign "^4.0.0" create-ecdh "^4.0.0" create-hash "^1.1.0" create-hmac "^1.1.0" diffie-hellman "^5.0.0" inherits "^2.0.1" pbkdf2 "^3.0.3" public-encrypt "^4.0.0" randombytes "^2.0.0" randomfill "^1.0.3" cssom@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw== cssom@~0.3.6: version "0.3.8" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== cssstyle@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== dependencies: cssom "~0.3.6" dash-ast@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/dash-ast/-/dash-ast-1.0.0.tgz#12029ba5fb2f8aa6f0a861795b23c1b4b6c27d37" integrity sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA== dashdash@^1.12.0: version "1.14.1" resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= dependencies: assert-plus "^1.0.0" data-urls@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" integrity sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ== dependencies: abab "^2.0.3" whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" de-indent@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0= debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: ms "2.0.0" debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: version "4.3.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== dependencies: ms "2.1.2" decamelize@^1.0.0, decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= decimal.js@^10.2.0: version "10.2.1" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.1.tgz#238ae7b0f0c793d3e3cea410108b35a2c01426a3" integrity sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw== decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== define-properties@^1.1.3, define-properties@~1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== dependencies: object-keys "^1.0.12" define-property@^0.2.5: version "0.2.5" resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= dependencies: is-descriptor "^0.1.0" define-property@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= dependencies: is-descriptor "^1.0.0" define-property@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== dependencies: is-descriptor "^1.0.2" isobject "^3.0.1" defined@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM= delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= deps-sort@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/deps-sort/-/deps-sort-2.0.1.tgz#9dfdc876d2bcec3386b6829ac52162cda9fa208d" integrity sha512-1orqXQr5po+3KI6kQb9A4jnXT1PBwggGl2d7Sq2xsnOeI9GPcE/tGcF9UiSZtZBM7MukY4cAh7MemS6tZYipfw== dependencies: JSONStream "^1.0.3" shasum-object "^1.0.0" subarg "^1.0.0" through2 "^2.0.0" des.js@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" integrity sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA== dependencies: inherits "^2.0.1" minimalistic-assert "^1.0.0" detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== detective@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b" integrity sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg== dependencies: acorn-node "^1.6.1" defined "^1.0.0" minimist "^1.1.1" diff-match-patch@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== dependencies: bn.js "^4.1.0" miller-rabin "^4.0.0" randombytes "^2.0.0" dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== dependencies: path-type "^4.0.0" docdash@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/docdash/-/docdash-1.2.0.tgz#f99dde5b8a89aa4ed083a3150383e042d06c7f49" integrity sha512-IYZbgYthPTspgqYeciRJNPhSwL51yer7HAwDXhF5p+H7mTDbPvY3PCk/QDjNxdPCpWkaJVFC4t7iCNB/t9E5Kw== doctrine@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" integrity sha1-N53Ocw9hZvds76TmcHoVmwLFpvo= dependencies: esutils "^2.0.2" isarray "^1.0.0" doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== dependencies: esutils "^2.0.2" doctrine@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== dependencies: esutils "^2.0.2" doctypes@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" integrity sha1-6oCxBqh1OHdOijpKWv4pPeSJ4Kk= domain-browser@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== domexception@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug== dependencies: webidl-conversions "^4.0.2" domexception@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" integrity sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg== dependencies: webidl-conversions "^5.0.0" duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= dependencies: readable-stream "^2.0.2" ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= dependencies: jsbn "~0.1.0" safer-buffer "^2.1.0" electron-to-chromium@^1.3.634: version "1.3.642" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.642.tgz#8b884f50296c2ae2a9997f024d0e3e57facc2b94" integrity sha512-cev+jOrz/Zm1i+Yh334Hed6lQVOkkemk2wRozfMF4MtTR7pxf3r3L5Rbd7uX1zMcEqVJ7alJBnJL7+JffkC6FQ== elliptic@^6.5.3: version "6.5.4" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== dependencies: bn.js "^4.11.9" brorand "^1.1.0" hash.js "^1.0.0" hmac-drbg "^1.0.1" inherits "^2.0.4" minimalistic-assert "^1.0.1" minimalistic-crypto-utils "^1.0.1" emittery@^0.7.1: version "0.7.2" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.2.tgz#25595908e13af0f5674ab419396e2fb394cdfa82" integrity sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ== emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== dependencies: once "^1.4.0" enquirer@^2.3.5: version "2.3.6" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== dependencies: ansi-colors "^4.1.1" entities@~2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== error-ex@^1.2.0, error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== dependencies: is-arrayish "^0.2.1" es-abstract@^1.17.0-next.1: version "1.17.7" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== dependencies: es-to-primitive "^1.2.1" function-bind "^1.1.1" has "^1.0.3" has-symbols "^1.0.1" is-callable "^1.2.2" is-regex "^1.1.1" object-inspect "^1.8.0" object-keys "^1.1.1" object.assign "^4.1.1" string.prototype.trimend "^1.0.1" string.prototype.trimstart "^1.0.1" es-abstract@^1.18.0-next.1: version "1.18.0-next.2" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.2.tgz#088101a55f0541f595e7e057199e27ddc8f3a5c2" integrity sha512-Ih4ZMFHEtZupnUh6497zEL4y2+w8+1ljnCyaTa+adcoafI1GOvMwFlDjBLfWR7y9VLfrjRJe9ocuHY1PSR9jjw== dependencies: call-bind "^1.0.2" es-to-primitive "^1.2.1" function-bind "^1.1.1" get-intrinsic "^1.0.2" has "^1.0.3" has-symbols "^1.0.1" is-callable "^1.2.2" is-negative-zero "^2.0.1" is-regex "^1.1.1" object-inspect "^1.9.0" object-keys "^1.1.1" object.assign "^4.1.2" string.prototype.trimend "^1.0.3" string.prototype.trimstart "^1.0.3" es-get-iterator@^1.0.1: version "1.1.1" resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.1.tgz#b93ddd867af16d5118e00881396533c1c6647ad9" integrity sha512-qorBw8Y7B15DVLaJWy6WdEV/ZkieBcu6QCq/xzWzGOKJqgG1j754vXRfZ3NY7HSShneqU43mPB4OkQBTkvHhFw== dependencies: call-bind "^1.0.0" get-intrinsic "^1.0.1" has-symbols "^1.0.1" is-arguments "^1.0.4" is-map "^2.0.1" is-set "^2.0.1" is-string "^1.0.5" isarray "^2.0.5" es-to-primitive@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== dependencies: is-callable "^1.1.4" is-date-object "^1.0.1" is-symbol "^1.0.2" escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= escape-string-regexp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== escodegen@^1.14.1: version "1.14.3" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== dependencies: esprima "^4.0.1" estraverse "^4.2.0" esutils "^2.0.2" optionator "^0.8.1" optionalDependencies: source-map "~0.6.1" eslint-config-esnext@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/eslint-config-esnext/-/eslint-config-esnext-4.1.0.tgz#8695b858fcf40d28c1aedca181f700528c7b60c6" integrity sha512-GhfVEXdqYKEIIj7j+Fw2SQdL9qyZMekgXfq6PyXM66cQw0B435ddjz3P3kxOBVihMRJ0xGYjosaveQz5Y6z0uA== dependencies: babel-eslint "^10.0.1" eslint "^6.8.0" eslint-plugin-babel "^5.2.1" eslint-plugin-import "^2.14.0" eslint-config-google@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.14.0.tgz#4f5f8759ba6e11b424294a219dbfa18c508bcc1a" integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw== eslint-config-matrix-org@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eslint-config-matrix-org/-/eslint-config-matrix-org-0.2.0.tgz#27571c7c3a9ab6cc1e9d0f3d53a3739d1b004a7f" integrity sha512-Hwgm1Qk0atjWgj5SWxpFPvxy6k6bDlPb9Pqjvb1i59nx8Zm6jDPy1C5vKQpkFpc0ntr+gJHa+ejYhuhaofu6Mw== dependencies: "@typescript-eslint/eslint-plugin" "^4.14.0" "@typescript-eslint/parser" "^4.14.0" babel-eslint "^10.1.0" eslint-config-google "^0.14.0" eslint-config-recommended "^4.1.0" eslint-plugin-babel "^5.3.1" typescript "^4.1.3" eslint-config-node@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/eslint-config-node/-/eslint-config-node-4.1.0.tgz#fc1f13946d83766d6b83b0e67699e2071a56f417" integrity sha512-Wz17xV5O2WFG8fGdMYEBdbiL6TL7YNJSJvSX9V4sXQownewfYmoqlly7wxqLkOUv/57pq6LnnotMiQQrrPjCqQ== dependencies: eslint "^6.8.0" eslint-config-esnext "^4.1.0" eslint-config-react-native@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/eslint-config-react-native/-/eslint-config-react-native-4.1.0.tgz#63e9401c7fac146804785f609e7df8f15b3e04eb" integrity sha512-kNND+cs+ztawH7wgajf/K6FfNshjlDsFDAkkFZF9HAXDgH1w1sNMIfTfwzufg0hOcSK7rbiL4qbG/gg/oR507Q== dependencies: eslint "^6.8.0" eslint-config-esnext "^4.1.0" eslint-plugin-react "^7.19.0" eslint-plugin-react-native "^3.8.1" eslint-config-recommended@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/eslint-config-recommended/-/eslint-config-recommended-4.1.0.tgz#1adff90e0716d439be471d192977f233de171a46" integrity sha512-2evA0SX1VqtyFiExmBI2WAO4XQCKlr7wmNELE8rcT5PyZY2ixsY881ofVZWKuI/dywpgLiES1gR/XUQcnVLRzQ== dependencies: eslint "^6.8.0" eslint-config-esnext "^4.1.0" eslint-config-node "^4.1.0" eslint-config-react-native "^4.1.0" eslint-import-resolver-node@^0.3.4: version "0.3.4" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717" integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA== dependencies: debug "^2.6.9" resolve "^1.13.1" eslint-module-utils@^2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz#579ebd094f56af7797d19c9866c9c9486629bfa6" integrity sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA== dependencies: debug "^2.6.9" pkg-dir "^2.0.0" eslint-plugin-babel@^5.2.1, eslint-plugin-babel@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/eslint-plugin-babel/-/eslint-plugin-babel-5.3.1.tgz#75a2413ffbf17e7be57458301c60291f2cfbf560" integrity sha512-VsQEr6NH3dj664+EyxJwO4FCYm/00JhYb3Sk3ft8o+fpKuIfQ9TaW6uVUfvwMXHcf/lsnRIoyFPsLMyiWCSL/g== dependencies: eslint-rule-composer "^0.3.0" eslint-plugin-import@^2.14.0: version "2.22.1" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz#0896c7e6a0cf44109a2d97b95903c2bb689d7702" integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw== dependencies: array-includes "^3.1.1" array.prototype.flat "^1.2.3" contains-path "^0.1.0" debug "^2.6.9" doctrine "1.5.0" eslint-import-resolver-node "^0.3.4" eslint-module-utils "^2.6.0" has "^1.0.3" minimatch "^3.0.4" object.values "^1.1.1" read-pkg-up "^2.0.0" resolve "^1.17.0" tsconfig-paths "^3.9.0" eslint-plugin-react-native-globals@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/eslint-plugin-react-native-globals/-/eslint-plugin-react-native-globals-0.1.2.tgz#ee1348bc2ceb912303ce6bdbd22e2f045ea86ea2" integrity sha512-9aEPf1JEpiTjcFAmmyw8eiIXmcNZOqaZyHO77wgm0/dWfT/oxC1SrIq8ET38pMxHYrcB6Uew+TzUVsBeczF88g== eslint-plugin-react-native@^3.8.1: version "3.10.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-native/-/eslint-plugin-react-native-3.10.0.tgz#240f7e6979a908af3dfd9ba9652434c33f4d64cd" integrity sha512-4f5+hHYYq5wFhB5eptkPEAR7FfvqbS7AzScUOANfAMZtYw5qgnCxRq45bpfBaQF+iyPMim5Q8pubcpvLv75NAg== dependencies: "@babel/traverse" "^7.7.4" eslint-plugin-react-native-globals "^0.1.1" eslint-plugin-react@^7.19.0: version "7.22.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.22.0.tgz#3d1c542d1d3169c45421c1215d9470e341707269" integrity sha512-p30tuX3VS+NWv9nQot9xIGAHBXR0+xJVaZriEsHoJrASGCJZDJ8JLNM0YqKqI0AKm6Uxaa1VUHoNEibxRCMQHA== dependencies: array-includes "^3.1.1" array.prototype.flatmap "^1.2.3" doctrine "^2.1.0" has "^1.0.3" jsx-ast-utils "^2.4.1 || ^3.0.0" object.entries "^1.1.2" object.fromentries "^2.0.2" object.values "^1.1.1" prop-types "^15.7.2" resolve "^1.18.1" string.prototype.matchall "^4.0.2" eslint-rule-composer@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== eslint-scope@^5.0.0, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== dependencies: esrecurse "^4.3.0" estraverse "^4.1.1" eslint-utils@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q== dependencies: eslint-visitor-keys "^1.1.0" eslint-utils@^2.0.0, eslint-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== dependencies: eslint-visitor-keys "^1.1.0" eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== eslint-visitor-keys@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8" integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== eslint@7.18.0: version "7.18.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.18.0.tgz#7fdcd2f3715a41fe6295a16234bd69aed2c75e67" integrity sha512-fbgTiE8BfUJZuBeq2Yi7J3RB3WGUQ9PNuNbmgi6jt9Iv8qrkxfy19Ds3OpL1Pm7zg3BtTVhvcUZbIRQ0wmSjAQ== dependencies: "@babel/code-frame" "^7.0.0" "@eslint/eslintrc" "^0.3.0" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" debug "^4.0.1" doctrine "^3.0.0" enquirer "^2.3.5" eslint-scope "^5.1.1" eslint-utils "^2.1.0" eslint-visitor-keys "^2.0.0" espree "^7.3.1" esquery "^1.2.0" esutils "^2.0.2" file-entry-cache "^6.0.0" functional-red-black-tree "^1.0.1" glob-parent "^5.0.0" globals "^12.1.0" ignore "^4.0.6" import-fresh "^3.0.0" imurmurhash "^0.1.4" is-glob "^4.0.0" js-yaml "^3.13.1" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" lodash "^4.17.20" minimatch "^3.0.4" natural-compare "^1.4.0" optionator "^0.9.1" progress "^2.0.0" regexpp "^3.1.0" semver "^7.2.1" strip-ansi "^6.0.0" strip-json-comments "^3.1.0" table "^6.0.4" text-table "^0.2.0" v8-compile-cache "^2.0.3" eslint@^6.8.0: version "6.8.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb" integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig== dependencies: "@babel/code-frame" "^7.0.0" ajv "^6.10.0" chalk "^2.1.0" cross-spawn "^6.0.5" debug "^4.0.1" doctrine "^3.0.0" eslint-scope "^5.0.0" eslint-utils "^1.4.3" eslint-visitor-keys "^1.1.0" espree "^6.1.2" esquery "^1.0.1" esutils "^2.0.2" file-entry-cache "^5.0.1" functional-red-black-tree "^1.0.1" glob-parent "^5.0.0" globals "^12.1.0" ignore "^4.0.6" import-fresh "^3.0.0" imurmurhash "^0.1.4" inquirer "^7.0.0" is-glob "^4.0.0" js-yaml "^3.13.1" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.3.0" lodash "^4.17.14" minimatch "^3.0.4" mkdirp "^0.5.1" natural-compare "^1.4.0" optionator "^0.8.3" progress "^2.0.0" regexpp "^2.0.1" semver "^6.1.2" strip-ansi "^5.2.0" strip-json-comments "^3.0.1" table "^5.2.3" text-table "^0.2.0" v8-compile-cache "^2.0.3" espree@^6.1.2: version "6.2.1" resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" integrity sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw== dependencies: acorn "^7.1.1" acorn-jsx "^5.2.0" eslint-visitor-keys "^1.1.0" espree@^7.3.0, espree@^7.3.1: version "7.3.1" resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== dependencies: acorn "^7.4.0" acorn-jsx "^5.3.1" eslint-visitor-keys "^1.3.0" esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== esquery@^1.0.1, esquery@^1.2.0: version "1.3.1" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.3.1.tgz#b78b5828aa8e214e29fb74c4d5b752e1c033da57" integrity sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ== dependencies: estraverse "^5.1.0" esrecurse@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== dependencies: estraverse "^5.2.0" estraverse@^4.1.1, estraverse@^4.2.0: version "4.3.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== estraverse@^5.1.0, estraverse@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== events@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/events/-/events-3.2.0.tgz#93b87c18f8efcd4202a461aec4dfc0556b639379" integrity sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg== evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== dependencies: md5.js "^1.3.4" safe-buffer "^5.1.1" exec-sh@^0.3.2: version "0.3.4" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.4.tgz#3a018ceb526cc6f6df2bb504b2bfe8e3a4934ec5" integrity sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A== execa@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== dependencies: cross-spawn "^6.0.0" get-stream "^4.0.0" is-stream "^1.1.0" npm-run-path "^2.0.0" p-finally "^1.0.0" signal-exit "^3.0.0" strip-eof "^1.0.0" execa@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== dependencies: cross-spawn "^7.0.0" get-stream "^5.0.0" human-signals "^1.1.1" is-stream "^2.0.0" merge-stream "^2.0.0" npm-run-path "^4.0.0" onetime "^5.1.0" signal-exit "^3.0.2" strip-final-newline "^2.0.0" exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= exorcist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/exorcist/-/exorcist-1.0.1.tgz#79316e3c4885845490f7bb405c0e5b5db1167c52" integrity sha1-eTFuPEiFhFSQ97tAXA5bXbEWfFI= dependencies: is-stream "~1.1.0" minimist "0.0.5" mkdirp "~0.5.1" mold-source-map "~0.4.0" expand-brackets@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= dependencies: debug "^2.3.3" define-property "^0.2.5" extend-shallow "^2.0.1" posix-character-classes "^0.1.0" regex-not "^1.0.0" snapdragon "^0.8.1" to-regex "^3.0.1" expect@^1.20.2: version "1.20.2" resolved "https://registry.yarnpkg.com/expect/-/expect-1.20.2.tgz#d458fe4c56004036bae3232416a3f6361f04f965" integrity sha1-1Fj+TFYAQDa64yMkFqP2Nh8E+WU= dependencies: define-properties "~1.1.2" has "^1.0.1" is-equal "^1.5.1" is-regex "^1.0.3" object-inspect "^1.1.0" object-keys "^1.0.9" tmatch "^2.0.1" expect@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/expect/-/expect-26.6.2.tgz#c6b996bf26bf3fe18b67b2d0f51fc981ba934417" integrity sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA== dependencies: "@jest/types" "^26.6.2" ansi-styles "^4.0.0" jest-get-type "^26.3.0" jest-matcher-utils "^26.6.2" jest-message-util "^26.6.2" jest-regex-util "^26.0.0" extend-shallow@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= dependencies: is-extendable "^0.1.0" extend-shallow@^3.0.0, extend-shallow@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= dependencies: assign-symbols "^1.0.0" is-extendable "^1.0.1" extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== external-editor@^3.0.3: version "3.1.0" resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== dependencies: chardet "^0.7.0" iconv-lite "^0.4.24" tmp "^0.0.33" extglob@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== dependencies: array-unique "^0.3.2" define-property "^1.0.0" expand-brackets "^2.1.4" extend-shallow "^2.0.1" fragment-cache "^0.2.1" regex-not "^1.0.0" snapdragon "^0.8.1" to-regex "^3.0.1" extsprintf@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= extsprintf@^1.2.0: version "1.4.0" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= fake-indexeddb@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-3.1.2.tgz#8073a12ed3b254f7afc064f3cc2629f0110a5303" integrity sha512-W60eRBrE8r9o/EePyyUc63sr2I/MI9p3zVwLlC1WI1xdmQVqqM6+wec9KDWDz2EZyvJKhrDvy3cGC6hK8L1pfg== dependencies: realistic-structured-clone "^2.0.1" setimmediate "^1.0.5" fast-deep-equal@^3.1.1: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== fast-glob@^3.1.1: version "3.2.5" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.5.tgz#7939af2a656de79a4f1901903ee8adcaa7cb9661" integrity sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" glob-parent "^5.1.0" merge2 "^1.3.0" micromatch "^4.0.2" picomatch "^2.2.1" fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= fast-safe-stringify@^2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== fastq@^1.6.0: version "1.10.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.10.0.tgz#74dbefccade964932cdf500473ef302719c652bb" integrity sha512-NL2Qc5L3iQEsyYzweq7qfgy5OtXCmGzGvhElGEd/SoFWEMOEczNh5s5ocaF01HDetxz+p8ecjNPA6cZxxIHmzA== dependencies: reusify "^1.0.4" fb-watchman@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" integrity sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg== dependencies: bser "2.1.1" figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== dependencies: escape-string-regexp "^1.0.5" file-entry-cache@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== dependencies: flat-cache "^2.0.1" file-entry-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.0.tgz#7921a89c391c6d93efec2169ac6bf300c527ea0a" integrity sha512-fqoO76jZ3ZnYrXLDRxBR1YvOvc0k844kcOg40bgsPrE25LAb/PDqTY+ho64Xh2c8ZXgIKldchCFHczG2UVRcWA== dependencies: flat-cache "^3.0.4" fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= dependencies: extend-shallow "^2.0.1" is-number "^3.0.0" repeat-string "^1.6.1" to-regex-range "^2.1.0" fill-range@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== dependencies: to-regex-range "^5.0.1" find-cache-dir@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" integrity sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ== dependencies: commondir "^1.0.1" make-dir "^2.0.0" pkg-dir "^3.0.0" find-up@^2.0.0, find-up@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= dependencies: locate-path "^2.0.0" find-up@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== dependencies: locate-path "^3.0.0" find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== dependencies: locate-path "^5.0.0" path-exists "^4.0.0" flat-cache@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== dependencies: flatted "^2.0.0" rimraf "2.6.3" write "1.0.3" flat-cache@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== dependencies: flatted "^3.1.0" rimraf "^3.0.2" flatted@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== flatted@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= foreach@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= form-data@^2.5.0: version "2.5.1" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== dependencies: asynckit "^0.4.0" combined-stream "^1.0.6" mime-types "^2.1.12" form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== dependencies: asynckit "^0.4.0" combined-stream "^1.0.6" mime-types "^2.1.12" fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= dependencies: map-cache "^0.2.2" fs-readdir-recursive@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz#e32fc030a2ccee44a6b5371308da54be0b397d27" integrity sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA== fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= fsevents@^2.1.2, fsevents@~2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.1.tgz#b209ab14c61012636c8863507edf7fb68cc54e9f" integrity sha512-YR47Eg4hChJGAB1O3yEAOkGO+rlzutoICGqGo9EZ4lKWokzZRSyIW1QmTzqjtw8MJdj9srP869CuWw/hyzSiBw== function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= functions-have-names@^1.2.0: version "1.2.2" resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.2.tgz#98d93991c39da9361f8e50b337c4f6e41f120e21" integrity sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA== gensync@^1.0.0-beta.1: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== get-assigned-identifiers@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz#6dbf411de648cbaf8d9169ebb0d2d576191e2ff1" integrity sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ== get-caller-file@^2.0.1: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== get-intrinsic@^1.0.1, get-intrinsic@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.0.2.tgz#6820da226e50b24894e08859469dc68361545d49" integrity sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg== dependencies: function-bind "^1.1.1" has "^1.0.3" has-symbols "^1.0.1" get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== get-stream@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== dependencies: pump "^3.0.0" get-stream@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== dependencies: pump "^3.0.0" get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= dependencies: assert-plus "^1.0.0" glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= dependencies: is-glob "^3.1.0" path-dirname "^1.0.0" glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0: version "5.1.1" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229" integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ== dependencies: is-glob "^4.0.1" glob@^7.0.0, glob@^7.1.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: version "7.1.6" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" minimatch "^3.0.4" once "^1.3.0" path-is-absolute "^1.0.0" globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== globals@^12.1.0: version "12.4.0" resolved "https://registry.yarnpkg.com/globals/-/globals-12.4.0.tgz#a18813576a41b00a24a97e7f815918c2e19925f8" integrity sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg== dependencies: type-fest "^0.8.1" globby@^11.0.1: version "11.0.2" resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.2.tgz#1af538b766a3b540ebfb58a32b2e2d5897321d83" integrity sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og== dependencies: array-union "^2.1.0" dir-glob "^3.0.1" fast-glob "^3.1.1" ignore "^5.1.4" merge2 "^1.3.0" slash "^3.0.0" graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.9, graceful-fs@^4.2.4: version "4.2.4" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= har-validator@~5.1.3: version "5.1.5" resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== dependencies: ajv "^6.12.3" har-schema "^2.0.0" has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== has-symbols@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== has-value@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= dependencies: get-value "^2.0.3" has-values "^0.1.4" isobject "^2.0.0" has-value@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= dependencies: get-value "^2.0.6" has-values "^1.0.0" isobject "^3.0.0" has-values@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= has-values@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= dependencies: is-number "^3.0.0" kind-of "^4.0.0" has@^1.0.0, has@^1.0.1, has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== dependencies: function-bind "^1.1.1" hash-base@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== dependencies: inherits "^2.0.4" readable-stream "^3.6.0" safe-buffer "^5.2.0" hash-sum@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/hash-sum/-/hash-sum-1.0.2.tgz#33b40777754c6432573c120cc3808bbd10d47f04" integrity sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ= hash.js@^1.0.0, hash.js@^1.0.3: version "1.1.7" resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== dependencies: inherits "^2.0.3" minimalistic-assert "^1.0.1" he@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= dependencies: hash.js "^1.0.3" minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" hosted-git-info@^2.1.4: version "2.8.8" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== html-encoding-sniffer@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" integrity sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ== dependencies: whatwg-encoding "^1.0.5" html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== htmlescape@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351" integrity sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E= http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= dependencies: assert-plus "^1.0.0" jsprim "^1.2.2" sshpk "^1.7.0" https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= human-signals@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== dependencies: safer-buffer ">= 2.1.2 < 3" ieee754@^1.1.4: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== ignore@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== ignore@^5.1.4: version "5.1.8" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== import-fresh@^3.0.0, import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== dependencies: parent-module "^1.0.0" resolve-from "^4.0.0" import-local@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.0.2.tgz#a8cfd0431d1de4a2199703d003e3e62364fa6db6" integrity sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA== dependencies: pkg-dir "^4.2.0" resolve-cwd "^3.0.0" imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= dependencies: once "^1.3.0" wrappy "1" inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== inherits@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= inline-source-map@~0.6.0: version "0.6.2" resolved "https://registry.yarnpkg.com/inline-source-map/-/inline-source-map-0.6.2.tgz#f9393471c18a79d1724f863fa38b586370ade2a5" integrity sha1-+Tk0ccGKedFyT4Y/o4tYY3Ct4qU= dependencies: source-map "~0.5.3" inquirer@^7.0.0: version "7.3.3" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA== dependencies: ansi-escapes "^4.2.1" chalk "^4.1.0" cli-cursor "^3.1.0" cli-width "^3.0.0" external-editor "^3.0.3" figures "^3.0.0" lodash "^4.17.19" mute-stream "0.0.8" run-async "^2.4.0" rxjs "^6.6.0" string-width "^4.1.0" strip-ansi "^6.0.0" through "^2.3.6" insert-module-globals@^7.2.1: version "7.2.1" resolved "https://registry.yarnpkg.com/insert-module-globals/-/insert-module-globals-7.2.1.tgz#d5e33185181a4e1f33b15f7bf100ee91890d5cb3" integrity sha512-ufS5Qq9RZN+Bu899eA9QCAYThY+gGW7oRkmb0vC93Vlyu/CFGcH0OYPEjVkDXA5FEbTt1+VWzdoOD3Ny9N+8tg== dependencies: JSONStream "^1.0.3" acorn-node "^1.5.2" combine-source-map "^0.8.0" concat-stream "^1.6.1" is-buffer "^1.1.0" path-is-absolute "^1.0.1" process "~0.11.0" through2 "^2.0.0" undeclared-identifiers "^1.1.2" xtend "^4.0.0" internal-slot@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.2.tgz#9c2e9fb3cd8e5e4256c6f45fe310067fcfa378a3" integrity sha512-2cQNfwhAfJIkU4KZPkDI+Gj5yNNnbqi40W9Gge6dfnk4TocEVm00B3bdiL+JINrbGJil2TeHvM4rETGzk/f/0g== dependencies: es-abstract "^1.17.0-next.1" has "^1.0.3" side-channel "^1.0.2" ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= dependencies: kind-of "^3.0.2" is-accessor-descriptor@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== dependencies: kind-of "^6.0.0" is-arguments@^1.0.4: version "1.1.0" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.0.tgz#62353031dfbee07ceb34656a6bde59efecae8dd9" integrity sha512-1Ij4lOMPl/xB5kBDn7I+b2ttPMKa8szhEIrXDuXQD/oe3HJLTLhqhgGspwgyGd6MOywBUqVvYicF72lkgDnIHg== dependencies: call-bind "^1.0.0" is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= is-arrow-function@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-arrow-function/-/is-arrow-function-2.0.3.tgz#29be2c2d8d9450852b8bbafb635ba7b8d8e87ec2" integrity sha1-Kb4sLY2UUIUri7r7Y1unuNjofsI= dependencies: is-callable "^1.0.4" is-bigint@^1.0.0, is-bigint@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.1.tgz#6923051dfcbc764278540b9ce0e6b3213aa5ebc2" integrity sha512-J0ELF4yHFxHy0cmSxZuheDOz2luOdVvqjwmEcj8H/L1JHeuEDSDbeRP+Dk9kFVk5RTFzbucJ2Kb9F7ixY2QaCg== is-binary-path@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= dependencies: binary-extensions "^1.0.0" is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== dependencies: binary-extensions "^2.0.0" is-boolean-object@^1.0.0, is-boolean-object@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.0.tgz#e2aaad3a3a8fca34c28f6eee135b156ed2587ff0" integrity sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA== dependencies: call-bind "^1.0.0" is-buffer@^1.1.0, is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== is-callable@^1.0.4, is-callable@^1.1.4, is-callable@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== is-ci@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== dependencies: ci-info "^2.0.0" is-core-module@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ== dependencies: has "^1.0.3" is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= dependencies: kind-of "^3.0.2" is-data-descriptor@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== dependencies: kind-of "^6.0.0" is-date-object@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== is-descriptor@^0.1.0: version "0.1.6" resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== dependencies: is-accessor-descriptor "^0.1.6" is-data-descriptor "^0.1.4" kind-of "^5.0.0" is-descriptor@^1.0.0, is-descriptor@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== dependencies: is-accessor-descriptor "^1.0.0" is-data-descriptor "^1.0.0" kind-of "^6.0.2" is-docker@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.1.1.tgz#4125a88e44e450d384e09047ede71adc2d144156" integrity sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw== is-equal@^1.5.1: version "1.6.1" resolved "https://registry.yarnpkg.com/is-equal/-/is-equal-1.6.1.tgz#74fafde5060fcaf187041c05f11f0b9f020bb9b3" integrity sha512-3/79QTolnfNFrxQAvqH8M+O01uGWsVq54BUPG2mXQH7zi4BE/0TY+fmA444t8xSBvIwyNMvsTmCZ5ViVDlqPJg== dependencies: es-get-iterator "^1.0.1" functions-have-names "^1.2.0" has "^1.0.3" is-arrow-function "^2.0.3" is-bigint "^1.0.0" is-boolean-object "^1.0.0" is-callable "^1.1.4" is-date-object "^1.0.1" is-generator-function "^1.0.7" is-number-object "^1.0.3" is-regex "^1.0.4" is-string "^1.0.4" is-symbol "^1.0.3" isarray "^2.0.5" object-inspect "^1.7.0" object.entries "^1.1.0" which-boxed-primitive "^1.0.1" which-collection "^1.0.0" is-expression@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-expression/-/is-expression-3.0.0.tgz#39acaa6be7fd1f3471dc42c7416e61c24317ac9f" integrity sha1-Oayqa+f9HzRx3ELHQW5hwkMXrJ8= dependencies: acorn "~4.0.2" object-assign "^4.0.1" is-extendable@^0.1.0, is-extendable@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= is-extendable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== dependencies: is-plain-object "^2.0.4" is-extglob@^2.1.0, is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= is-fullwidth-code-point@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== is-generator-fn@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== is-generator-function@^1.0.7: version "1.0.8" resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.8.tgz#dfb5c2b120e02b0a8d9d2c6806cd5621aa922f7b" integrity sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ== is-glob@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= dependencies: is-extglob "^2.1.0" is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== dependencies: is-extglob "^2.1.1" is-map@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" integrity sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg== is-negative-zero@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== is-number-object@^1.0.3, is-number-object@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" integrity sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw== is-number@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= dependencies: kind-of "^3.0.2" is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== dependencies: isobject "^3.0.1" is-potential-custom-element-name@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397" integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c= is-promise@^2.0.0: version "2.2.2" resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== is-regex@^1.0.3, is-regex@^1.0.4, is-regex@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== dependencies: has-symbols "^1.0.1" is-set@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.2.tgz#90755fa4c2562dc1c5d4024760d6119b94ca18ec" integrity sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g== is-stream@^1.1.0, is-stream@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= is-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== is-string@^1.0.4, is-string@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6" integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== dependencies: has-symbols "^1.0.1" is-typed-array@^1.1.3: version "1.1.4" resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.4.tgz#1f66f34a283a3c94a4335434661ca53fff801120" integrity sha512-ILaRgn4zaSrVNXNGtON6iFNotXW3hAPF3+0fB1usg2jFlWqo5fEDdmJkz0zBfoi7Dgskr8Khi2xZ8cXqZEfXNA== dependencies: available-typed-arrays "^1.0.2" call-bind "^1.0.0" es-abstract "^1.18.0-next.1" foreach "^2.0.5" has-symbols "^1.0.1" is-typedarray@^1.0.0, is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= is-weakmap@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2" integrity sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA== is-weakset@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.1.tgz#e9a0af88dbd751589f5e50d80f4c98b780884f83" integrity sha512-pi4vhbhVHGLxohUw7PhGsueT4vRGFoXhP7+RGN0jKIv9+8PWYCQTqtADngrxOm2g46hoH0+g8uZZBzMrvVGDmw== is-windows@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== dependencies: is-docker "^2.0.0" isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= dependencies: isarray "1.0.0" isobject@^3.0.0, isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= istanbul-lib-coverage@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz#f5944a37c70b550b02a78a5c3b2055b280cec8ec" integrity sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg== istanbul-lib-instrument@^4.0.0, istanbul-lib-instrument@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz#873c6fff897450118222774696a3f28902d77c1d" integrity sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ== dependencies: "@babel/core" "^7.7.5" "@istanbuljs/schema" "^0.1.2" istanbul-lib-coverage "^3.0.0" semver "^6.3.0" istanbul-lib-report@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6" integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw== dependencies: istanbul-lib-coverage "^3.0.0" make-dir "^3.0.0" supports-color "^7.1.0" istanbul-lib-source-maps@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz#75743ce6d96bb86dc7ee4352cf6366a23f0b1ad9" integrity sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg== dependencies: debug "^4.1.1" istanbul-lib-coverage "^3.0.0" source-map "^0.6.1" istanbul-reports@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.2.tgz#d593210e5000683750cb09fc0644e4b6e27fd53b" integrity sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw== dependencies: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" jest-changed-files@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-26.6.2.tgz#f6198479e1cc66f22f9ae1e22acaa0b429c042d0" integrity sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ== dependencies: "@jest/types" "^26.6.2" execa "^4.0.0" throat "^5.0.0" jest-cli@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-26.6.3.tgz#43117cfef24bc4cd691a174a8796a532e135e92a" integrity sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg== dependencies: "@jest/core" "^26.6.3" "@jest/test-result" "^26.6.2" "@jest/types" "^26.6.2" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" import-local "^3.0.2" is-ci "^2.0.0" jest-config "^26.6.3" jest-util "^26.6.2" jest-validate "^26.6.2" prompts "^2.0.1" yargs "^15.4.1" jest-config@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-26.6.3.tgz#64f41444eef9eb03dc51d5c53b75c8c71f645349" integrity sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg== dependencies: "@babel/core" "^7.1.0" "@jest/test-sequencer" "^26.6.3" "@jest/types" "^26.6.2" babel-jest "^26.6.3" chalk "^4.0.0" deepmerge "^4.2.2" glob "^7.1.1" graceful-fs "^4.2.4" jest-environment-jsdom "^26.6.2" jest-environment-node "^26.6.2" jest-get-type "^26.3.0" jest-jasmine2 "^26.6.3" jest-regex-util "^26.0.0" jest-resolve "^26.6.2" jest-util "^26.6.2" jest-validate "^26.6.2" micromatch "^4.0.2" pretty-format "^26.6.2" jest-diff@^26.0.0, jest-diff@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394" integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA== dependencies: chalk "^4.0.0" diff-sequences "^26.6.2" jest-get-type "^26.3.0" pretty-format "^26.6.2" jest-docblock@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5" integrity sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w== dependencies: detect-newline "^3.0.0" jest-each@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-26.6.2.tgz#02526438a77a67401c8a6382dfe5999952c167cb" integrity sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A== dependencies: "@jest/types" "^26.6.2" chalk "^4.0.0" jest-get-type "^26.3.0" jest-util "^26.6.2" pretty-format "^26.6.2" jest-environment-jsdom@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz#78d09fe9cf019a357009b9b7e1f101d23bd1da3e" integrity sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q== dependencies: "@jest/environment" "^26.6.2" "@jest/fake-timers" "^26.6.2" "@jest/types" "^26.6.2" "@types/node" "*" jest-mock "^26.6.2" jest-util "^26.6.2" jsdom "^16.4.0" jest-environment-node@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-26.6.2.tgz#824e4c7fb4944646356f11ac75b229b0035f2b0c" integrity sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag== dependencies: "@jest/environment" "^26.6.2" "@jest/fake-timers" "^26.6.2" "@jest/types" "^26.6.2" "@types/node" "*" jest-mock "^26.6.2" jest-util "^26.6.2" jest-get-type@^26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== jest-haste-map@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" integrity sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w== dependencies: "@jest/types" "^26.6.2" "@types/graceful-fs" "^4.1.2" "@types/node" "*" anymatch "^3.0.3" fb-watchman "^2.0.0" graceful-fs "^4.2.4" jest-regex-util "^26.0.0" jest-serializer "^26.6.2" jest-util "^26.6.2" jest-worker "^26.6.2" micromatch "^4.0.2" sane "^4.0.3" walker "^1.0.7" optionalDependencies: fsevents "^2.1.2" jest-jasmine2@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz#adc3cf915deacb5212c93b9f3547cd12958f2edd" integrity sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg== dependencies: "@babel/traverse" "^7.1.0" "@jest/environment" "^26.6.2" "@jest/source-map" "^26.6.2" "@jest/test-result" "^26.6.2" "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" co "^4.6.0" expect "^26.6.2" is-generator-fn "^2.0.0" jest-each "^26.6.2" jest-matcher-utils "^26.6.2" jest-message-util "^26.6.2" jest-runtime "^26.6.3" jest-snapshot "^26.6.2" jest-util "^26.6.2" pretty-format "^26.6.2" throat "^5.0.0" jest-leak-detector@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-26.6.2.tgz#7717cf118b92238f2eba65054c8a0c9c653a91af" integrity sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg== dependencies: jest-get-type "^26.3.0" pretty-format "^26.6.2" jest-localstorage-mock@^2.4.6: version "2.4.6" resolved "https://registry.yarnpkg.com/jest-localstorage-mock/-/jest-localstorage-mock-2.4.6.tgz#ebb9481943bf52e0f8c864ae4858eb791d68149d" integrity sha512-8n6tuqscEShpvC7vkq3BPabOGGszD1cdLAKTtTtCRqH2bWJIVfpV4aIhrDJstV7xLOjo56wSVZkoMbT7dWLIVg== jest-matcher-utils@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz#8e6fd6e863c8b2d31ac6472eeb237bc595e53e7a" integrity sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw== dependencies: chalk "^4.0.0" jest-diff "^26.6.2" jest-get-type "^26.3.0" pretty-format "^26.6.2" jest-message-util@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-26.6.2.tgz#58173744ad6fc0506b5d21150b9be56ef001ca07" integrity sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA== dependencies: "@babel/code-frame" "^7.0.0" "@jest/types" "^26.6.2" "@types/stack-utils" "^2.0.0" chalk "^4.0.0" graceful-fs "^4.2.4" micromatch "^4.0.2" pretty-format "^26.6.2" slash "^3.0.0" stack-utils "^2.0.2" jest-mock@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.6.2.tgz#d6cb712b041ed47fe0d9b6fc3474bc6543feb302" integrity sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew== dependencies: "@jest/types" "^26.6.2" "@types/node" "*" jest-pnp-resolver@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c" integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== jest-regex-util@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28" integrity sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A== jest-resolve-dependencies@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.3.tgz#6680859ee5d22ee5dcd961fe4871f59f4c784fb6" integrity sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg== dependencies: "@jest/types" "^26.6.2" jest-regex-util "^26.0.0" jest-snapshot "^26.6.2" jest-resolve@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-26.6.2.tgz#a3ab1517217f469b504f1b56603c5bb541fbb507" integrity sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ== dependencies: "@jest/types" "^26.6.2" chalk "^4.0.0" graceful-fs "^4.2.4" jest-pnp-resolver "^1.2.2" jest-util "^26.6.2" read-pkg-up "^7.0.1" resolve "^1.18.1" slash "^3.0.0" jest-runner@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-26.6.3.tgz#2d1fed3d46e10f233fd1dbd3bfaa3fe8924be159" integrity sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ== dependencies: "@jest/console" "^26.6.2" "@jest/environment" "^26.6.2" "@jest/test-result" "^26.6.2" "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" emittery "^0.7.1" exit "^0.1.2" graceful-fs "^4.2.4" jest-config "^26.6.3" jest-docblock "^26.0.0" jest-haste-map "^26.6.2" jest-leak-detector "^26.6.2" jest-message-util "^26.6.2" jest-resolve "^26.6.2" jest-runtime "^26.6.3" jest-util "^26.6.2" jest-worker "^26.6.2" source-map-support "^0.5.6" throat "^5.0.0" jest-runtime@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-26.6.3.tgz#4f64efbcfac398331b74b4b3c82d27d401b8fa2b" integrity sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw== dependencies: "@jest/console" "^26.6.2" "@jest/environment" "^26.6.2" "@jest/fake-timers" "^26.6.2" "@jest/globals" "^26.6.2" "@jest/source-map" "^26.6.2" "@jest/test-result" "^26.6.2" "@jest/transform" "^26.6.2" "@jest/types" "^26.6.2" "@types/yargs" "^15.0.0" chalk "^4.0.0" cjs-module-lexer "^0.6.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" glob "^7.1.3" graceful-fs "^4.2.4" jest-config "^26.6.3" jest-haste-map "^26.6.2" jest-message-util "^26.6.2" jest-mock "^26.6.2" jest-regex-util "^26.0.0" jest-resolve "^26.6.2" jest-snapshot "^26.6.2" jest-util "^26.6.2" jest-validate "^26.6.2" slash "^3.0.0" strip-bom "^4.0.0" yargs "^15.4.1" jest-serializer@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-26.6.2.tgz#d139aafd46957d3a448f3a6cdabe2919ba0742d1" integrity sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g== dependencies: "@types/node" "*" graceful-fs "^4.2.4" jest-snapshot@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-26.6.2.tgz#f3b0af1acb223316850bd14e1beea9837fb39c84" integrity sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og== dependencies: "@babel/types" "^7.0.0" "@jest/types" "^26.6.2" "@types/babel__traverse" "^7.0.4" "@types/prettier" "^2.0.0" chalk "^4.0.0" expect "^26.6.2" graceful-fs "^4.2.4" jest-diff "^26.6.2" jest-get-type "^26.3.0" jest-haste-map "^26.6.2" jest-matcher-utils "^26.6.2" jest-message-util "^26.6.2" jest-resolve "^26.6.2" natural-compare "^1.4.0" pretty-format "^26.6.2" semver "^7.3.2" jest-util@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.2.tgz#907535dbe4d5a6cb4c47ac9b926f6af29576cbc1" integrity sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q== dependencies: "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" graceful-fs "^4.2.4" is-ci "^2.0.0" micromatch "^4.0.2" jest-validate@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-26.6.2.tgz#23d380971587150467342911c3d7b4ac57ab20ec" integrity sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ== dependencies: "@jest/types" "^26.6.2" camelcase "^6.0.0" chalk "^4.0.0" jest-get-type "^26.3.0" leven "^3.1.0" pretty-format "^26.6.2" jest-watcher@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-26.6.2.tgz#a5b683b8f9d68dbcb1d7dae32172d2cca0592975" integrity sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ== dependencies: "@jest/test-result" "^26.6.2" "@jest/types" "^26.6.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" jest-util "^26.6.2" string-length "^4.0.1" jest-worker@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== dependencies: "@types/node" "*" merge-stream "^2.0.0" supports-color "^7.0.0" jest@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/jest/-/jest-26.6.3.tgz#40e8fdbe48f00dfa1f0ce8121ca74b88ac9148ef" integrity sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q== dependencies: "@jest/core" "^26.6.3" import-local "^3.0.2" jest-cli "^26.6.3" js-stringify@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/js-stringify/-/js-stringify-1.0.2.tgz#1736fddfd9724f28a3682adc6230ae7e4e9679db" integrity sha1-Fzb939lyTyijaCrcYjCufk6Weds= "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== js-yaml@^3.13.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== dependencies: argparse "^1.0.7" esprima "^4.0.0" js2xmlparser@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/js2xmlparser/-/js2xmlparser-4.0.1.tgz#670ef71bc5661f089cc90481b99a05a1227ae3bd" integrity sha512-KrPTolcw6RocpYjdC7pL7v62e55q7qOMHvLX1UCLc5AAS8qeJ6nukarEJAF2KL2PZxlbGueEbINqZR2bDe/gUw== dependencies: xmlcreate "^2.0.3" jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= jsdoc@^3.6.6: version "3.6.6" resolved "https://registry.yarnpkg.com/jsdoc/-/jsdoc-3.6.6.tgz#9fe162bbdb13ee7988bf74352b5147565bcfd8e1" integrity sha512-znR99e1BHeyEkSvgDDpX0sTiTu+8aQyDl9DawrkOGZTTW8hv0deIFXx87114zJ7gRaDZKVQD/4tr1ifmJp9xhQ== dependencies: "@babel/parser" "^7.9.4" bluebird "^3.7.2" catharsis "^0.8.11" escape-string-regexp "^2.0.0" js2xmlparser "^4.0.1" klaw "^3.0.0" markdown-it "^10.0.0" markdown-it-anchor "^5.2.7" marked "^0.8.2" mkdirp "^1.0.4" requizzle "^0.2.3" strip-json-comments "^3.1.0" taffydb "2.6.2" underscore "~1.10.2" jsdom@^16.4.0: version "16.4.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.4.0.tgz#36005bde2d136f73eee1a830c6d45e55408edddb" integrity sha512-lYMm3wYdgPhrl7pDcRmvzPhhrGVBeVhPIqeHjzeiHN3DFmD1RBpbExbi8vU7BJdH8VAZYovR8DMt0PNNDM7k8w== dependencies: abab "^2.0.3" acorn "^7.1.1" acorn-globals "^6.0.0" cssom "^0.4.4" cssstyle "^2.2.0" data-urls "^2.0.0" decimal.js "^10.2.0" domexception "^2.0.1" escodegen "^1.14.1" html-encoding-sniffer "^2.0.1" is-potential-custom-element-name "^1.0.0" nwsapi "^2.2.0" parse5 "5.1.1" request "^2.88.2" request-promise-native "^1.0.8" saxes "^5.0.0" symbol-tree "^3.2.4" tough-cookie "^3.0.1" w3c-hr-time "^1.0.2" w3c-xmlserializer "^2.0.0" webidl-conversions "^6.1.0" whatwg-encoding "^1.0.5" whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" ws "^7.2.3" xml-name-validator "^3.0.0" jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= json-parse-even-better-errors@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== json-schema-traverse@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== json-schema@0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= json5@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== dependencies: minimist "^1.2.0" json5@^2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== dependencies: minimist "^1.2.5" jsonparse@^1.2.0: version "1.3.1" resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= dependencies: assert-plus "1.0.0" extsprintf "1.3.0" json-schema "0.2.3" verror "1.10.0" jstransformer@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3" integrity sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM= dependencies: is-promise "^2.0.0" promise "^7.0.1" "jsx-ast-utils@^2.4.1 || ^3.0.0": version "3.2.0" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz#41108d2cec408c3453c1bbe8a4aae9e1e2bd8f82" integrity sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q== dependencies: array-includes "^3.1.2" object.assign "^4.1.2" kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= dependencies: is-buffer "^1.1.5" kind-of@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= dependencies: is-buffer "^1.1.5" kind-of@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== kind-of@^6.0.0, kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== klaw@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/klaw/-/klaw-3.0.0.tgz#b11bec9cf2492f06756d6e809ab73a2910259146" integrity sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g== dependencies: graceful-fs "^4.1.9" kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== labeled-stream-splicer@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/labeled-stream-splicer/-/labeled-stream-splicer-2.0.2.tgz#42a41a16abcd46fd046306cf4f2c3576fffb1c21" integrity sha512-Ca4LSXFFZUjPScRaqOcFxneA0VpKZr4MMYCljyQr4LIewTLb3Y0IUTIsnBBsVubIeEfxeSZpSjSsRM8APEQaAw== dependencies: inherits "^2.0.1" stream-splicer "^2.0.0" lazy-cache@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4= leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== levn@^0.3.0, levn@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= dependencies: prelude-ls "~1.1.2" type-check "~0.3.2" levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== dependencies: prelude-ls "^1.2.1" type-check "~0.4.0" lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= linkify-it@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf" integrity sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw== dependencies: uc.micro "^1.0.1" load-json-file@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" integrity sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg= dependencies: graceful-fs "^4.1.2" parse-json "^2.2.0" pify "^2.0.0" strip-bom "^3.0.0" locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= dependencies: p-locate "^2.0.0" path-exists "^3.0.0" locate-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== dependencies: p-locate "^3.0.0" path-exists "^3.0.0" locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== dependencies: p-locate "^4.1.0" lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= lodash.memoize@~3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" integrity sha1-LcvSwofLwKVcxCMovQxzYVDVPj8= lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20: version "4.17.20" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== lodash@^4.17.4: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== loglevel@^1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197" integrity sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw== longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc= loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== dependencies: js-tokens "^3.0.0 || ^4.0.0" lru-cache@^4.1.5: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== dependencies: pseudomap "^1.0.2" yallist "^2.1.2" lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== dependencies: yallist "^4.0.0" make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== dependencies: pify "^4.0.1" semver "^5.6.0" make-dir@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== dependencies: semver "^6.0.0" makeerror@1.0.x: version "1.0.11" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" integrity sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw= dependencies: tmpl "1.0.x" map-cache@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= map-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= dependencies: object-visit "^1.0.0" markdown-it-anchor@^5.2.7: version "5.3.0" resolved "https://registry.yarnpkg.com/markdown-it-anchor/-/markdown-it-anchor-5.3.0.tgz#d549acd64856a8ecd1bea58365ef385effbac744" integrity sha512-/V1MnLL/rgJ3jkMWo84UR+K+jF1cxNG1a+KwqeXqTIJ+jtA8aWSHuigx8lTzauiIjBDbwF3NcWQMotd0Dm39jA== markdown-it@^10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc" integrity sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg== dependencies: argparse "^1.0.7" entities "~2.0.0" linkify-it "^2.0.0" mdurl "^1.0.1" uc.micro "^1.0.5" marked@^0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/marked/-/marked-0.8.2.tgz#4faad28d26ede351a7a1aaa5fec67915c869e355" integrity sha512-EGwzEeCcLniFX51DhTpmTom+dSA/MG/OBUDjnWtHbEnjAH180VzUeAw+oE4+Zv+CoYBWyRlYOTR0N8SO9R1PVw== matrix-mock-request@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-1.2.3.tgz#56b15d86e2601a9b48a854844396d18caab649c8" integrity sha512-Tr7LDHweTW8Ql4C8XhGQFGMzuh+HmPjOcQqrHH1qfSesq0cwdPWanvdnllNjeHoAMcZ43HpMFMzFZfNW1/6HYg== dependencies: bluebird "^3.5.0" expect "^1.20.2" md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== dependencies: hash-base "^3.0.0" inherits "^2.0.1" safe-buffer "^5.1.2" mdurl@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4= merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== merge2@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== dependencies: arr-diff "^4.0.0" array-unique "^0.3.2" braces "^2.3.1" define-property "^2.0.2" extend-shallow "^3.0.2" extglob "^2.0.4" fragment-cache "^0.2.1" kind-of "^6.0.2" nanomatch "^1.2.9" object.pick "^1.3.0" regex-not "^1.0.0" snapdragon "^0.8.1" to-regex "^3.0.2" micromatch@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== dependencies: braces "^3.0.1" picomatch "^2.0.5" miller-rabin@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== dependencies: bn.js "^4.0.0" brorand "^1.0.1" mime-db@1.45.0: version "1.45.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea" integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w== mime-types@^2.1.12, mime-types@~2.1.19: version "2.1.28" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.28.tgz#1160c4757eab2c5363888e005273ecf79d2a0ecd" integrity sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ== dependencies: mime-db "1.45.0" mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== min-indent@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= minimatch@^3.0.2, minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== dependencies: brace-expansion "^1.1.7" minimist@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.5.tgz#d7aa327bcecf518f9106ac6b8f003fa3bcea8566" integrity sha1-16oye87PUY+RBqxrjwA/o7zqhWY= minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== dependencies: for-in "^1.0.2" is-extendable "^1.0.1" mkdirp-classic@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== mkdirp@^0.5.1, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== dependencies: minimist "^1.2.5" mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== module-deps@^6.2.3: version "6.2.3" resolved "https://registry.yarnpkg.com/module-deps/-/module-deps-6.2.3.tgz#15490bc02af4b56cf62299c7c17cba32d71a96ee" integrity sha512-fg7OZaQBcL4/L+AK5f4iVqf9OMbCclXfy/znXRxTVhJSeW5AIlS9AwheYwDaXM3lVW7OBeaeUEY3gbaC6cLlSA== dependencies: JSONStream "^1.0.3" browser-resolve "^2.0.0" cached-path-relative "^1.0.2" concat-stream "~1.6.0" defined "^1.0.0" detective "^5.2.0" duplexer2 "^0.1.2" inherits "^2.0.1" parents "^1.0.0" readable-stream "^2.0.2" resolve "^1.4.0" stream-combiner2 "^1.1.1" subarg "^1.0.0" through2 "^2.0.0" xtend "^4.0.0" mold-source-map@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/mold-source-map/-/mold-source-map-0.4.0.tgz#cf67e0b31c47ab9badb5c9c25651862127bb8317" integrity sha1-z2fgsxxHq5uttcnCVlGGISe7gxc= dependencies: convert-source-map "^1.1.0" through "~2.2.7" ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== mute-stream@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== dependencies: arr-diff "^4.0.0" array-unique "^0.3.2" define-property "^2.0.2" extend-shallow "^3.0.2" fragment-cache "^0.2.1" is-windows "^1.0.2" kind-of "^6.0.2" object.pick "^1.3.0" regex-not "^1.0.0" snapdragon "^0.8.1" to-regex "^3.0.1" natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= neo-async@^2.6.1: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== node-dir@^0.1.10: version "0.1.17" resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" integrity sha1-X1Zl2TNRM1yqvvjxxVRRbPXx5OU= dependencies: minimatch "^3.0.2" node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= node-modules-regexp@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= node-notifier@^8.0.0: version "8.0.1" resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-8.0.1.tgz#f86e89bbc925f2b068784b31f382afdc6ca56be1" integrity sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA== dependencies: growly "^1.3.0" is-wsl "^2.2.0" semver "^7.3.2" shellwords "^0.1.1" uuid "^8.3.0" which "^2.0.2" node-releases@^1.1.69: version "1.1.70" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.70.tgz#66e0ed0273aa65666d7fe78febe7634875426a08" integrity sha512-Slf2s69+2/uAD79pVVQo8uSiC34+g8GWY8UH2Qtqv34ZfhYrxpYpfzs9Js9d6O0mbDmALuxaTlplnBTnSELcrw== normalize-package-data@^2.3.2, normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== dependencies: hosted-git-info "^2.1.4" resolve "^1.10.0" semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" normalize-path@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= dependencies: remove-trailing-separator "^1.0.1" normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= dependencies: path-key "^2.0.0" npm-run-path@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== dependencies: path-key "^3.0.0" nwsapi@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== oauth-sign@~0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= object-copy@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= dependencies: copy-descriptor "^0.1.0" define-property "^0.2.5" kind-of "^3.0.3" object-inspect@^1.1.0, object-inspect@^1.7.0, object-inspect@^1.8.0, object-inspect@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== object-keys@^1.0.12, object-keys@^1.0.9, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= dependencies: isobject "^3.0.0" object.assign@^4.1.0, object.assign@^4.1.1, object.assign@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" has-symbols "^1.0.1" object-keys "^1.1.1" object.entries@^1.1.0, object.entries@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.3.tgz#c601c7f168b62374541a07ddbd3e2d5e4f7711a6" integrity sha512-ym7h7OZebNS96hn5IJeyUmaWhaSM4SVtAPPfNLQEI2MYWCO2egsITb9nab2+i/Pwibx+R0mtn+ltKJXRSeTMGg== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" es-abstract "^1.18.0-next.1" has "^1.0.3" object.fromentries@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.3.tgz#13cefcffa702dc67750314a3305e8cb3fad1d072" integrity sha512-IDUSMXs6LOSJBWE++L0lzIbSqHl9KDCfff2x/JSEIDtEUavUnyMYC2ZGay/04Zq4UT8lvd4xNhU4/YHKibAOlw== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" es-abstract "^1.18.0-next.1" has "^1.0.3" object.pick@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= dependencies: isobject "^3.0.1" object.values@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.2.tgz#7a2015e06fcb0f546bd652486ce8583a4731c731" integrity sha512-MYC0jvJopr8EK6dPBiO8Nb9mvjdypOachO5REGk6MXzujbBrAisKo3HmdEI6kZDL6fC31Mwee/5YbtMebixeag== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" es-abstract "^1.18.0-next.1" has "^1.0.3" "olm@https://packages.matrix.org/npm/olm/olm-3.2.1.tgz": version "3.2.1" resolved "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz#d623d76f99c3518dde68be8c86618d68bc7b004a" once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= dependencies: wrappy "1" onetime@^5.1.0: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== dependencies: mimic-fn "^2.1.0" optionator@^0.8.1, optionator@^0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== dependencies: deep-is "~0.1.3" fast-levenshtein "~2.0.6" levn "~0.3.0" prelude-ls "~1.1.2" type-check "~0.3.2" word-wrap "~1.2.3" optionator@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== dependencies: deep-is "^0.1.3" fast-levenshtein "^2.0.6" levn "^0.4.1" prelude-ls "^1.2.1" type-check "^0.4.0" word-wrap "^1.2.3" os-browserify@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= p-each-series@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.2.0.tgz#105ab0357ce72b202a8a8b94933672657b5e2a9a" integrity sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA== p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= p-limit@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== dependencies: p-try "^1.0.0" p-limit@^2.0.0, p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== dependencies: p-try "^2.0.0" p-locate@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= dependencies: p-limit "^1.1.0" p-locate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== dependencies: p-limit "^2.0.0" p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== dependencies: p-limit "^2.2.0" p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== pako@~1.0.5: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== dependencies: callsites "^3.0.0" parents@^1.0.0, parents@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/parents/-/parents-1.0.1.tgz#fedd4d2bf193a77745fe71e371d73c3307d9c751" integrity sha1-/t1NK/GTp3dF/nHjcdc8MwfZx1E= dependencies: path-platform "~0.11.15" parse-asn1@^5.0.0, parse-asn1@^5.1.5: version "5.1.6" resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.6.tgz#385080a3ec13cb62a62d39409cb3e88844cdaed4" integrity sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw== dependencies: asn1.js "^5.2.0" browserify-aes "^1.0.0" evp_bytestokey "^1.0.0" pbkdf2 "^3.0.3" safe-buffer "^5.1.1" parse-json@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= dependencies: error-ex "^1.2.0" parse-json@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== dependencies: "@babel/code-frame" "^7.0.0" error-ex "^1.3.1" json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" parse5@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== pascalcase@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= path-browserify@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== path-dirname@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= path-exists@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= path-key@^2.0.0, path-key@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= path-key@^3.0.0, path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== path-parse@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== path-platform@~0.11.15: version "0.11.15" resolved "https://registry.yarnpkg.com/path-platform/-/path-platform-0.11.15.tgz#e864217f74c36850f0852b78dc7bf7d4a5721bf2" integrity sha1-6GQhf3TDaFDwhSt43Hv31KVyG/I= path-type@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM= dependencies: pify "^2.0.0" path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== pbkdf2@^3.0.3: version "3.1.1" resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.1.tgz#cb8724b0fada984596856d1a6ebafd3584654b94" integrity sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg== dependencies: create-hash "^1.1.2" create-hmac "^1.1.4" ripemd160 "^2.0.1" safe-buffer "^5.0.1" sha.js "^2.4.8" performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= pify@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== pirates@^4.0.0, pirates@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87" integrity sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA== dependencies: node-modules-regexp "^1.0.0" pkg-dir@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s= dependencies: find-up "^2.1.0" pkg-dir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== dependencies: find-up "^3.0.0" pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== dependencies: find-up "^4.0.0" posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= pretty-format@^26.0.0, pretty-format@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== dependencies: "@jest/types" "^26.6.2" ansi-regex "^5.0.0" ansi-styles "^4.0.0" react-is "^17.0.1" private@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg== process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== process@~0.11.0: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== promise@^7.0.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== dependencies: asap "~2.0.3" prompts@^2.0.1: version "2.4.0" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.0.tgz#4aa5de0723a231d1ee9121c40fdf663df73f61d7" integrity sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ== dependencies: kleur "^3.0.3" sisteransi "^1.0.5" prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== dependencies: loose-envify "^1.4.0" object-assign "^4.1.1" react-is "^16.8.1" pseudomap@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= psl@^1.1.28: version "1.8.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== public-encrypt@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0" integrity sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q== dependencies: bn.js "^4.1.0" browserify-rsa "^4.0.0" create-hash "^1.1.0" parse-asn1 "^5.0.0" randombytes "^2.0.1" safe-buffer "^5.1.2" pug-attrs@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/pug-attrs/-/pug-attrs-2.0.4.tgz#b2f44c439e4eb4ad5d4ef25cac20d18ad28cc336" integrity sha512-TaZ4Z2TWUPDJcV3wjU3RtUXMrd3kM4Wzjbe3EWnSsZPsJ3LDI0F3yCnf2/W7PPFF+edUFQ0HgDL1IoxSz5K8EQ== dependencies: constantinople "^3.0.1" js-stringify "^1.0.1" pug-runtime "^2.0.5" pug-code-gen@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/pug-code-gen/-/pug-code-gen-2.0.3.tgz#122eb9ada9b5bf601705fe15aaa0a7d26bc134ab" integrity sha512-r9sezXdDuZJfW9J91TN/2LFbiqDhmltTFmGpHTsGdrNGp3p4SxAjjXEfnuK2e4ywYsRIVP0NeLbSAMHUcaX1EA== dependencies: constantinople "^3.1.2" doctypes "^1.1.0" js-stringify "^1.0.1" pug-attrs "^2.0.4" pug-error "^1.3.3" pug-runtime "^2.0.5" void-elements "^2.0.1" with "^5.0.0" pug-error@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/pug-error/-/pug-error-1.3.3.tgz#f342fb008752d58034c185de03602dd9ffe15fa6" integrity sha512-qE3YhESP2mRAWMFJgKdtT5D7ckThRScXRwkfo+Erqga7dyJdY3ZquspprMCj/9sJ2ijm5hXFWQE/A3l4poMWiQ== pug-filters@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/pug-filters/-/pug-filters-3.1.1.tgz#ab2cc82db9eeccf578bda89130e252a0db026aa7" integrity sha512-lFfjNyGEyVWC4BwX0WyvkoWLapI5xHSM3xZJFUhx4JM4XyyRdO8Aucc6pCygnqV2uSgJFaJWW3Ft1wCWSoQkQg== dependencies: clean-css "^4.1.11" constantinople "^3.0.1" jstransformer "1.0.0" pug-error "^1.3.3" pug-walk "^1.1.8" resolve "^1.1.6" uglify-js "^2.6.1" pug-lexer@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/pug-lexer/-/pug-lexer-4.1.0.tgz#531cde48c7c0b1fcbbc2b85485c8665e31489cfd" integrity sha512-i55yzEBtjm0mlplW4LoANq7k3S8gDdfC6+LThGEvsK4FuobcKfDAwt6V4jKPH9RtiE3a2Akfg5UpafZ1OksaPA== dependencies: character-parser "^2.1.1" is-expression "^3.0.0" pug-error "^1.3.3" pug-linker@^3.0.6: version "3.0.6" resolved "https://registry.yarnpkg.com/pug-linker/-/pug-linker-3.0.6.tgz#f5bf218b0efd65ce6670f7afc51658d0f82989fb" integrity sha512-bagfuHttfQOpANGy1Y6NJ+0mNb7dD2MswFG2ZKj22s8g0wVsojpRlqveEQHmgXXcfROB2RT6oqbPYr9EN2ZWzg== dependencies: pug-error "^1.3.3" pug-walk "^1.1.8" pug-load@^2.0.12: version "2.0.12" resolved "https://registry.yarnpkg.com/pug-load/-/pug-load-2.0.12.tgz#d38c85eb85f6e2f704dea14dcca94144d35d3e7b" integrity sha512-UqpgGpyyXRYgJs/X60sE6SIf8UBsmcHYKNaOccyVLEuT6OPBIMo6xMPhoJnqtB3Q3BbO4Z3Bjz5qDsUWh4rXsg== dependencies: object-assign "^4.1.0" pug-walk "^1.1.8" pug-parser@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/pug-parser/-/pug-parser-5.0.1.tgz#03e7ada48b6840bd3822f867d7d90f842d0ffdc9" integrity sha512-nGHqK+w07p5/PsPIyzkTQfzlYfuqoiGjaoqHv1LjOv2ZLXmGX1O+4Vcvps+P4LhxZ3drYSljjq4b+Naid126wA== dependencies: pug-error "^1.3.3" token-stream "0.0.1" pug-runtime@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/pug-runtime/-/pug-runtime-2.0.5.tgz#6da7976c36bf22f68e733c359240d8ae7a32953a" integrity sha512-P+rXKn9un4fQY77wtpcuFyvFaBww7/91f3jHa154qU26qFAnOe6SW1CbIDcxiG5lLK9HazYrMCCuDvNgDQNptw== pug-strip-comments@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/pug-strip-comments/-/pug-strip-comments-1.0.4.tgz#cc1b6de1f6e8f5931cf02ec66cdffd3f50eaf8a8" integrity sha512-i5j/9CS4yFhSxHp5iKPHwigaig/VV9g+FgReLJWWHEHbvKsbqL0oP/K5ubuLco6Wu3Kan5p7u7qk8A4oLLh6vw== dependencies: pug-error "^1.3.3" pug-walk@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/pug-walk/-/pug-walk-1.1.8.tgz#b408f67f27912f8c21da2f45b7230c4bd2a5ea7a" integrity sha512-GMu3M5nUL3fju4/egXwZO0XLi6fW/K3T3VTgFQ14GxNi8btlxgT5qZL//JwZFm/2Fa64J/PNS8AZeys3wiMkVA== pug@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/pug/-/pug-2.0.4.tgz#ee7682ec0a60494b38d48a88f05f3b0ac931377d" integrity sha512-XhoaDlvi6NIzL49nu094R2NA6P37ijtgMDuWE+ofekDChvfKnzFal60bhSdiy8y2PBO6fmz3oMEIcfpBVRUdvw== dependencies: pug-code-gen "^2.0.2" pug-filters "^3.1.1" pug-lexer "^4.1.0" pug-linker "^3.0.6" pug-load "^2.0.12" pug-parser "^5.0.1" pug-runtime "^2.0.5" pug-strip-comments "^1.0.4" pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== dependencies: end-of-stream "^1.1.0" once "^1.3.1" punycode@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= punycode@^1.3.2: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== qs@^6.9.6: version "6.9.6" resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.6.tgz#26ed3c8243a431b2924aca84cc90471f35d5a0ee" integrity sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ== qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== querystring-es3@~0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= querystring@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== dependencies: safe-buffer "^5.1.0" randomfill@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" integrity sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== dependencies: randombytes "^2.0.5" safe-buffer "^5.1.0" react-ace@^6.5.0: version "6.6.0" resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-6.6.0.tgz#a79457ef03c3b1f8d4fc598a003b1d6ad464f1a0" integrity sha512-Jehhp8bxa8kqiXk07Jzy+uD5qZMBwo43O+raniGHjdX7Qk93xFkKaAz8LxtUVZPJGlRnV5ODMNj0qHwDSN+PBw== dependencies: "@babel/polyfill" "^7.4.4" brace "^0.11.1" diff-match-patch "^1.0.4" lodash.get "^4.4.2" lodash.isequal "^4.5.0" prop-types "^15.7.2" react-docgen@^5.3.0: version "5.3.1" resolved "https://registry.yarnpkg.com/react-docgen/-/react-docgen-5.3.1.tgz#940b519646a6c285c2950b96512aed59e8f90934" integrity sha512-YG7YujVTwlLslr2Ny8nQiUfbBuEwKsLHJdQTSdEga1eY/nRFh/7LjCWUn6ogYhu2WDKg4z+6W/BJtUi+DPUIlA== dependencies: "@babel/core" "^7.7.5" "@babel/runtime" "^7.7.6" ast-types "^0.14.2" commander "^2.19.0" doctrine "^3.0.0" neo-async "^2.6.1" node-dir "^0.1.10" strip-indent "^3.0.0" react-frame-component@^4.1.1: version "4.1.3" resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-4.1.3.tgz#64c09dd29574720879c5f43ee36c17d8ae74d4ec" integrity sha512-4PurhctiqnmC1F5prPZ+LdsalH7pZ3SFA5xoc0HBe8mSHctdLLt4Cr2WXfXOoajHBYq/yiipp9zOgx+vy8GiEA== react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== react-is@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== read-only-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/read-only-stream/-/read-only-stream-2.0.0.tgz#2724fd6a8113d73764ac288d4386270c1dbf17f0" integrity sha1-JyT9aoET1zdkrCiNQ4YnDB2/F/A= dependencies: readable-stream "^2.0.2" read-pkg-up@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4= dependencies: find-up "^2.0.0" read-pkg "^2.0.0" read-pkg-up@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== dependencies: find-up "^4.1.0" read-pkg "^5.2.0" type-fest "^0.8.1" read-pkg@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg= dependencies: load-json-file "^2.0.0" normalize-package-data "^2.3.2" path-type "^2.0.0" read-pkg@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== dependencies: "@types/normalize-package-data" "^2.4.0" normalize-package-data "^2.5.0" parse-json "^5.0.0" type-fest "^0.6.0" readable-stream@^2.0.2, readable-stream@^2.2.2, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== dependencies: core-util-is "~1.0.0" inherits "~2.0.3" isarray "~1.0.0" process-nextick-args "~2.0.0" safe-buffer "~5.1.1" string_decoder "~1.1.1" util-deprecate "~1.0.1" readable-stream@^3.5.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== dependencies: inherits "^2.0.3" string_decoder "^1.1.1" util-deprecate "^1.0.1" readdirp@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== dependencies: graceful-fs "^4.1.11" micromatch "^3.1.10" readable-stream "^2.0.2" readdirp@~3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== dependencies: picomatch "^2.2.1" realistic-structured-clone@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-2.0.2.tgz#2f8ec225b1f9af20efc79ac96a09043704414959" integrity sha512-5IEvyfuMJ4tjQOuKKTFNvd+H9GSbE87IcendSBannE28PTrbolgaVg5DdEApRKhtze794iXqVUFKV60GLCNKEg== dependencies: core-js "^2.5.3" domexception "^1.0.1" typeson "^5.8.2" typeson-registry "^1.0.0-alpha.20" recast@^0.17.3: version "0.17.6" resolved "https://registry.yarnpkg.com/recast/-/recast-0.17.6.tgz#64ae98d0d2dfb10ff92ff5fb9ffb7371823b69fa" integrity sha512-yoQRMRrK1lszNtbkGyM4kN45AwylV5hMiuEveUBlxytUViWevjvX6w+tzJt1LH4cfUhWt4NZvy3ThIhu6+m5wQ== dependencies: ast-types "0.12.4" esprima "~4.0.0" private "^0.1.8" source-map "~0.6.1" regenerate-unicode-properties@^8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec" integrity sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA== dependencies: regenerate "^1.4.0" regenerate@^1.4.0: version "1.4.2" resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== regenerator-runtime@^0.13.4: version "0.13.7" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== regenerator-transform@^0.14.2: version "0.14.5" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4" integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw== dependencies: "@babel/runtime" "^7.8.4" regex-not@^1.0.0, regex-not@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== dependencies: extend-shallow "^3.0.2" safe-regex "^1.1.0" regexp.prototype.flags@^1.3.0: version "1.3.1" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz#7ef352ae8d159e758c0eadca6f8fcb4eef07be26" integrity sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA== dependencies: call-bind "^1.0.2" define-properties "^1.1.3" regexpp@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== regexpp@^3.0.0, regexpp@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2" integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== regexpu-core@^4.7.1: version "4.7.1" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ== dependencies: regenerate "^1.4.0" regenerate-unicode-properties "^8.2.0" regjsgen "^0.5.1" regjsparser "^0.6.4" unicode-match-property-ecmascript "^1.0.4" unicode-match-property-value-ecmascript "^1.2.0" regjsgen@^0.5.1: version "0.5.2" resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733" integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A== regjsparser@^0.6.4: version "0.6.6" resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.6.tgz#6d8c939d1a654f78859b08ddcc4aa777f3fa800a" integrity sha512-jjyuCp+IEMIm3N1H1LLTJW1EISEJV9+5oHdEyrt43Pg9cDSb6rrLZei2cVWpl0xTjmmlpec/lEQGYgM7xfpGCQ== dependencies: jsesc "~0.5.0" remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= repeat-element@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== repeat-string@^1.5.2, repeat-string@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= request-promise-core@1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.4.tgz#3eedd4223208d419867b78ce815167d10593a22f" integrity sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw== dependencies: lodash "^4.17.19" request-promise-native@^1.0.8: version "1.0.9" resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.9.tgz#e407120526a5efdc9a39b28a5679bf47b9d9dc28" integrity sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g== dependencies: request-promise-core "1.1.4" stealthy-require "^1.1.1" tough-cookie "^2.3.3" request@^2.88.2: version "2.88.2" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" caseless "~0.12.0" combined-stream "~1.0.6" extend "~3.0.2" forever-agent "~0.6.1" form-data "~2.3.2" har-validator "~5.1.3" http-signature "~1.2.0" is-typedarray "~1.0.0" isstream "~0.1.2" json-stringify-safe "~5.0.1" mime-types "~2.1.19" oauth-sign "~0.9.0" performance-now "^2.1.0" qs "~6.5.2" safe-buffer "^5.1.2" tough-cookie "~2.5.0" tunnel-agent "^0.6.0" uuid "^3.3.2" require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= require-from-string@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== requizzle@^0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/requizzle/-/requizzle-0.2.3.tgz#4675c90aacafb2c036bd39ba2daa4a1cb777fded" integrity sha512-YanoyJjykPxGHii0fZP0uUPEXpvqfBDxWV7s6GKAiiOsiqhX6vHNyW3Qzdmqp/iq/ExbhaGbVrjB4ruEVSM4GQ== dependencies: lodash "^4.17.14" resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== dependencies: resolve-from "^5.0.0" resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== resolve-from@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= resolve@^1.1.4, resolve@^1.1.6, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.18.1, resolve@^1.4.0: version "1.19.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== dependencies: is-core-module "^2.1.0" path-parse "^1.0.6" restore-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== dependencies: onetime "^5.1.0" signal-exit "^3.0.2" ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== right-align@^0.1.1: version "0.1.3" resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" integrity sha1-YTObci/mo1FWiSENJOFMlhSGE+8= dependencies: align-text "^0.1.1" rimraf@2.6.3: version "2.6.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== dependencies: glob "^7.1.3" rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== dependencies: glob "^7.1.3" ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== dependencies: hash-base "^3.0.0" inherits "^2.0.1" rsvp@^4.8.4: version "4.8.5" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== run-parallel@^1.1.9: version "1.1.10" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.10.tgz#60a51b2ae836636c81377df16cb107351bcd13ef" integrity sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw== rxjs@^6.6.0: version "6.6.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== dependencies: tslib "^1.9.0" safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== safe-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= dependencies: ret "~0.1.10" "safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== sane@^4.0.3: version "4.1.0" resolved "https://registry.yarnpkg.com/sane/-/sane-4.1.0.tgz#ed881fd922733a6c461bc189dc2b6c006f3ffded" integrity sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA== dependencies: "@cnakazawa/watch" "^1.0.3" anymatch "^2.0.0" capture-exit "^2.0.0" exec-sh "^0.3.2" execa "^1.0.0" fb-watchman "^2.0.0" micromatch "^3.1.4" minimist "^1.1.1" walker "~1.0.5" saxes@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== dependencies: xmlchars "^2.2.0" "semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== semver@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== semver@^7.2.1, semver@^7.3.2: version "7.3.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97" integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw== dependencies: lru-cache "^6.0.0" set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= set-value@^2.0.0, set-value@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== dependencies: extend-shallow "^2.0.1" is-extendable "^0.1.1" is-plain-object "^2.0.3" split-string "^3.0.1" setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= sha.js@^2.4.0, sha.js@^2.4.8: version "2.4.11" resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== dependencies: inherits "^2.0.1" safe-buffer "^5.0.1" shasum-object@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/shasum-object/-/shasum-object-1.0.0.tgz#0b7b74ff5b66ecf9035475522fa05090ac47e29e" integrity sha512-Iqo5rp/3xVi6M4YheapzZhhGPVs0yZwHj7wvwQ1B9z8H6zk+FEnI7y3Teq7qwnekfEhu8WmG2z0z4iWZaxLWVg== dependencies: fast-safe-stringify "^2.0.7" shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= dependencies: shebang-regex "^1.0.0" shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== dependencies: shebang-regex "^3.0.0" shebang-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= shebang-regex@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== shell-quote@^1.6.1: version "1.7.2" resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2" integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== shellwords@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== side-channel@^1.0.2, side-channel@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== dependencies: call-bind "^1.0.0" get-intrinsic "^1.0.2" object-inspect "^1.9.0" signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== simple-concat@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== slash@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== slice-ansi@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== dependencies: ansi-styles "^3.2.0" astral-regex "^1.0.0" is-fullwidth-code-point "^2.0.0" slice-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== dependencies: ansi-styles "^4.0.0" astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== dependencies: define-property "^1.0.0" isobject "^3.0.0" snapdragon-util "^3.0.1" snapdragon-util@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== dependencies: kind-of "^3.2.0" snapdragon@^0.8.1: version "0.8.2" resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== dependencies: base "^0.11.1" debug "^2.2.0" define-property "^0.2.5" extend-shallow "^2.0.1" map-cache "^0.2.2" source-map "^0.5.6" source-map-resolve "^0.5.0" use "^3.1.0" source-map-resolve@^0.5.0: version "0.5.3" resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== dependencies: atob "^2.1.2" decode-uri-component "^0.2.0" resolve-url "^0.2.1" source-map-url "^0.4.0" urix "^0.1.0" source-map-support@^0.5.16, source-map-support@^0.5.6, source-map-support@~0.5.19: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== dependencies: buffer-from "^1.0.0" source-map "^0.6.0" source-map-url@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= source-map@^0.5.0, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== source-map@^0.7.3, source-map@~0.7.2: version "0.7.3" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== spdx-correct@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== dependencies: spdx-expression-parse "^3.0.0" spdx-license-ids "^3.0.0" spdx-exceptions@^2.1.0: version "2.3.0" resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== spdx-expression-parse@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== dependencies: spdx-exceptions "^2.1.0" spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: version "3.0.7" resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65" integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ== split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== dependencies: extend-shallow "^3.0.0" sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== dependencies: asn1 "~0.2.3" assert-plus "^1.0.0" bcrypt-pbkdf "^1.0.0" dashdash "^1.12.0" ecc-jsbn "~0.1.1" getpass "^0.1.1" jsbn "~0.1.0" safer-buffer "^2.0.2" tweetnacl "~0.14.0" stack-utils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.3.tgz#cd5f030126ff116b78ccb3c027fe302713b61277" integrity sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw== dependencies: escape-string-regexp "^2.0.0" static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= dependencies: define-property "^0.2.5" object-copy "^0.1.0" stealthy-require@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= stream-browserify@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== dependencies: inherits "~2.0.4" readable-stream "^3.5.0" stream-combiner2@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/stream-combiner2/-/stream-combiner2-1.1.1.tgz#fb4d8a1420ea362764e21ad4780397bebcb41cbe" integrity sha1-+02KFCDqNidk4hrUeAOXvry0HL4= dependencies: duplexer2 "~0.1.0" readable-stream "^2.0.2" stream-http@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-3.1.1.tgz#0370a8017cf8d050b9a8554afe608f043eaff564" integrity sha512-S7OqaYu0EkFpgeGFb/NPOoPLxFko7TPqtEeFg5DXPB4v/KETHG0Ln6fRFrNezoelpaDKmycEmmZ81cC9DAwgYg== dependencies: builtin-status-codes "^3.0.0" inherits "^2.0.4" readable-stream "^3.6.0" xtend "^4.0.2" stream-splicer@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/stream-splicer/-/stream-splicer-2.0.1.tgz#0b13b7ee2b5ac7e0609a7463d83899589a363fcd" integrity sha512-Xizh4/NPuYSyAXyT7g8IvdJ9HJpxIGL9PjyhtywCZvvP0OPIdqyrr4dMikeuvY8xahpdKEBlBTySe583totajg== dependencies: inherits "^2.0.1" readable-stream "^2.0.2" string-length@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.1.tgz#4a973bf31ef77c4edbceadd6af2611996985f8a1" integrity sha512-PKyXUd0LK0ePjSOnWn34V2uD6acUWev9uy0Ft05k0E8xRW+SKcA0F7eMr7h5xlzfn+4O3N+55rduYyet3Jk+jw== dependencies: char-regex "^1.0.2" strip-ansi "^6.0.0" string-width@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== dependencies: emoji-regex "^7.0.1" is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" string-width@^4.1.0, string-width@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== dependencies: emoji-regex "^8.0.0" is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" string.prototype.matchall@^4.0.2: version "4.0.3" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.3.tgz#24243399bc31b0a49d19e2b74171a15653ec996a" integrity sha512-OBxYDA2ifZQ2e13cP82dWFMaCV9CGF8GzmN4fljBVw5O5wep0lu4gacm1OL6MjROoUnB8VbkWRThqkV2YFLNxw== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" es-abstract "^1.18.0-next.1" has-symbols "^1.0.1" internal-slot "^1.0.2" regexp.prototype.flags "^1.3.0" side-channel "^1.0.3" string.prototype.trimend@^1.0.1, string.prototype.trimend@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz#a22bd53cca5c7cf44d7c9d5c732118873d6cd18b" integrity sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" string.prototype.trimstart@^1.0.1, string.prototype.trimstart@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz#9b4cb590e123bb36564401d59824298de50fd5aa" integrity sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg== dependencies: call-bind "^1.0.0" define-properties "^1.1.3" string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== dependencies: safe-buffer "~5.2.0" string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== dependencies: safe-buffer "~5.1.0" strip-ansi@^5.1.0, strip-ansi@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== dependencies: ansi-regex "^4.1.0" strip-ansi@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== dependencies: ansi-regex "^5.0.0" strip-bom@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4= dependencies: is-utf8 "^0.2.0" strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= strip-bom@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== strip-eof@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= strip-final-newline@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== strip-indent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== dependencies: min-indent "^1.0.0" strip-json-comments@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= strip-json-comments@^3.0.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== subarg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" integrity sha1-9izxdYHplrSPyWVpn1TAauJouNI= dependencies: minimist "^1.1.0" supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== dependencies: has-flag "^3.0.0" supports-color@^7.0.0, supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== dependencies: has-flag "^4.0.0" supports-hyperlinks@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.1.0.tgz#f663df252af5f37c5d49bbd7eeefa9e0b9e59e47" integrity sha512-zoE5/e+dnEijk6ASB6/qrK+oYdm2do1hjoLWrqUC/8WEIW1gbxFcKuBof7sW8ArN6e+AYvsE8HBGiVRWL/F5CA== dependencies: has-flag "^4.0.0" supports-color "^7.0.0" symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== syntax-error@^1.1.1: version "1.4.0" resolved "https://registry.yarnpkg.com/syntax-error/-/syntax-error-1.4.0.tgz#2d9d4ff5c064acb711594a3e3b95054ad51d907c" integrity sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w== dependencies: acorn-node "^1.2.0" table@^5.2.3: version "5.4.6" resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug== dependencies: ajv "^6.10.2" lodash "^4.17.14" slice-ansi "^2.1.0" string-width "^3.0.0" table@^6.0.4: version "6.0.7" resolved "https://registry.yarnpkg.com/table/-/table-6.0.7.tgz#e45897ffbcc1bcf9e8a87bf420f2c9e5a7a52a34" integrity sha512-rxZevLGTUzWna/qBLObOe16kB2RTnnbhciwgPbMMlazz1yZGVEgnZK762xyVdVznhqxrfCeBMmMkgOOaPwjH7g== dependencies: ajv "^7.0.2" lodash "^4.17.20" slice-ansi "^4.0.0" string-width "^4.2.0" taffydb@2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/taffydb/-/taffydb-2.6.2.tgz#7cbcb64b5a141b6a2efc2c5d2c67b4e150b2a268" integrity sha1-fLy2S1oUG2ou/CxdLGe04VCyomg= terminal-link@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" integrity sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ== dependencies: ansi-escapes "^4.2.1" supports-hyperlinks "^2.0.0" terser@^5.5.1: version "5.5.1" resolved "https://registry.yarnpkg.com/terser/-/terser-5.5.1.tgz#540caa25139d6f496fdea056e414284886fb2289" integrity sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ== dependencies: commander "^2.20.0" source-map "~0.7.2" source-map-support "~0.5.19" test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== dependencies: "@istanbuljs/schema" "^0.1.2" glob "^7.1.4" minimatch "^3.0.4" text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= throat@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== through2@^2.0.0: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== dependencies: readable-stream "~2.3.6" xtend "~4.0.1" "through@>=2.2.7 <3", through@^2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= through@~2.2.7: version "2.2.7" resolved "https://registry.yarnpkg.com/through/-/through-2.2.7.tgz#6e8e21200191d4eb6a99f6f010df46aa1c6eb2bd" integrity sha1-bo4hIAGR1OtqmfbwEN9Gqhxusr0= timers-browserify@^1.0.1: version "1.4.2" resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-1.4.2.tgz#c9c58b575be8407375cb5e2462dacee74359f41d" integrity sha1-ycWLV1voQHN1y14kYtrO50NZ9B0= dependencies: process "~0.11.0" tmatch@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/tmatch/-/tmatch-2.0.1.tgz#0c56246f33f30da1b8d3d72895abaf16660f38cf" integrity sha1-DFYkbzPzDaG409colauvFmYPOM8= tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== dependencies: os-tmpdir "~1.0.2" tmpl@1.0.x: version "1.0.4" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= to-fast-properties@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc= to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= to-object-path@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= dependencies: kind-of "^3.0.2" to-regex-range@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= dependencies: is-number "^3.0.0" repeat-string "^1.6.1" to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== dependencies: is-number "^7.0.0" to-regex@^3.0.1, to-regex@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== dependencies: define-property "^2.0.2" extend-shallow "^3.0.2" regex-not "^1.0.2" safe-regex "^1.1.0" token-stream@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/token-stream/-/token-stream-0.0.1.tgz#ceeefc717a76c4316f126d0b9dbaa55d7e7df01a" integrity sha1-zu78cXp2xDFvEm0LnbqlXX598Bo= tough-cookie@^2.3.3, tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== dependencies: psl "^1.1.28" punycode "^2.1.1" tough-cookie@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-3.0.1.tgz#9df4f57e739c26930a018184887f4adb7dca73b2" integrity sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg== dependencies: ip-regex "^2.1.0" psl "^1.1.28" punycode "^2.1.1" tr46@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.0.2.tgz#03273586def1595ae08fedb38d7733cee91d2479" integrity sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg== dependencies: punycode "^2.1.1" ts-map@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/ts-map/-/ts-map-1.0.3.tgz#1c4d218dec813d2103b7e04e4bcf348e1471c1ff" integrity sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w== tsconfig-paths@^3.9.0: version "3.9.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b" integrity sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw== dependencies: "@types/json5" "^0.0.29" json5 "^1.0.1" minimist "^1.2.0" strip-bom "^3.0.0" tsconfig@^5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-5.0.3.tgz#5f4278e701800967a8fc383fd19648878f2a6e3a" integrity sha1-X0J45wGACWeo/Dg/0ZZIh48qbjo= dependencies: any-promise "^1.3.0" parse-json "^2.2.0" strip-bom "^2.0.0" strip-json-comments "^2.0.0" tsify@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/tsify/-/tsify-5.0.2.tgz#ba3c9f8de239e89ba3aab6b83d65b569ef8b68c3" integrity sha512-Pdo3ZO8CAgbQgNcFRBmfbgsPP+4TsD0itbSF5YgTnxKBXfg6WkQ79e4/bqBaq/7cEYa7vIOM1pHxnux8rJJnzg== dependencies: convert-source-map "^1.1.0" fs.realpath "^1.0.0" object-assign "^4.1.0" semver "^6.1.0" through2 "^2.0.0" tsconfig "^5.0.3" tslib@^1.8.1, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== tslib@^2.0.1: version "2.1.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== tsutils@^3.17.1: version "3.19.1" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.19.1.tgz#d8566e0c51c82f32f9c25a4d367cd62409a547a9" integrity sha512-GEdoBf5XI324lu7ycad7s6laADfnAqCw6wLGI+knxvw9vsIYBaJfYdmeCEG3FMMUiSm3OGgNb+m6utsWf5h9Vw== dependencies: tslib "^1.8.1" tty-browserify@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" integrity sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw== tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= dependencies: safe-buffer "^5.0.1" tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== dependencies: prelude-ls "^1.2.1" type-check@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= dependencies: prelude-ls "~1.1.2" type-detect@4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== type-fest@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1" integrity sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ== type-fest@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== type-fest@^0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== dependencies: is-typedarray "^1.0.0" typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= typescript@^3.2.2, typescript@^3.7.5: version "3.9.7" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa" integrity sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw== typescript@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.38" resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.38.tgz#42b19c21997ab80e38b3c67d6c1aad1499fea5b9" integrity sha512-6lt2IhbNT9hyow5hljZqjWtVDXBIaC1X8bBGlBva0Pod2f42g23bVqww09ruquwSC48I8BSSCPi+B2dFHM5ihQ== dependencies: base64-arraybuffer-es6 "^0.6.0" typeson "^5.18.2" whatwg-url "^8.1.0" typeson@^5.18.2, typeson@^5.8.2: version "5.18.2" resolved "https://registry.yarnpkg.com/typeson/-/typeson-5.18.2.tgz#0d217fc0e11184a66aa7ca0076d9aa7707eb7bc2" integrity sha512-Vetd+OGX05P4qHyHiSLdHZ5Z5GuQDrHHwSdjkqho9NSCYVSLSfRMjklD/unpHH8tXBR9Z/R05rwJSuMpMFrdsw== uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== uglify-js@^2.6.1: version "2.8.29" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" integrity sha1-KcVzMUgFe7Th913zW3qcty5qWd0= dependencies: source-map "~0.5.1" yargs "~3.10.0" optionalDependencies: uglify-to-browserify "~1.0.0" uglify-to-browserify@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" integrity sha1-bgkk1r2mta/jSeOabWMoUKD4grc= umd@^3.0.0: version "3.0.3" resolved "https://registry.yarnpkg.com/umd/-/umd-3.0.3.tgz#aa9fe653c42b9097678489c01000acb69f0b26cf" integrity sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow== undeclared-identifiers@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/undeclared-identifiers/-/undeclared-identifiers-1.1.3.tgz#9254c1d37bdac0ac2b52de4b6722792d2a91e30f" integrity sha512-pJOW4nxjlmfwKApE4zvxLScM/njmwj/DiUBv7EabwE4O8kRUy+HIwxQtZLBPll/jx1LJyBcqNfB3/cpv9EZwOw== dependencies: acorn-node "^1.3.0" dash-ast "^1.0.0" get-assigned-identifiers "^1.2.0" simple-concat "^1.0.0" xtend "^4.0.1" underscore@^1.9.1: version "1.12.0" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.0.tgz#4814940551fc80587cef7840d1ebb0f16453be97" integrity sha512-21rQzss/XPMjolTiIezSu3JAjgagXKROtNrYFEOWK109qY1Uv2tVjPTZ1ci2HgvQDA16gHYSthQIJfB+XId/rQ== underscore@~1.10.2: version "1.10.2" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.10.2.tgz#73d6aa3668f3188e4adb0f1943bd12cfd7efaaaf" integrity sha512-N4P+Q/BuyuEKFJ43B9gYuOj4TQUHXX+j2FqguVOpjkssLUUrnJofCcBccJSCoeturDoZU6GorDTHSvUDlSQbTg== unhomoglyph@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/unhomoglyph/-/unhomoglyph-1.0.6.tgz#ea41f926d0fcf598e3b8bb2980c2ddac66b081d3" integrity sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg== unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ== unicode-match-property-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c" integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg== dependencies: unicode-canonical-property-names-ecmascript "^1.0.4" unicode-property-aliases-ecmascript "^1.0.4" unicode-match-property-value-ecmascript@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531" integrity sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ== unicode-property-aliases-ecmascript@^1.0.4: version "1.1.0" resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4" integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg== union-value@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== dependencies: arr-union "^3.1.0" get-value "^2.0.6" is-extendable "^0.1.1" set-value "^2.0.1" unset-value@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= dependencies: has-value "^0.3.1" isobject "^3.0.0" upath@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== dependencies: punycode "^2.1.0" urix@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= url@~0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= dependencies: punycode "1.3.2" querystring "0.2.0" use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= util@0.10.3: version "0.10.3" resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" integrity sha1-evsa/lCAUkZInj23/g7TeTNqwPk= dependencies: inherits "2.0.1" util@~0.12.0: version "0.12.3" resolved "https://registry.yarnpkg.com/util/-/util-0.12.3.tgz#971bb0292d2cc0c892dab7c6a5d37c2bec707888" integrity sha512-I8XkoQwE+fPQEhy9v012V+TSdH2kp9ts29i20TaaDUXsg7x/onePbhFJUExBfv/2ay1ZOp/Vsm3nDlmnFGSAog== dependencies: inherits "^2.0.3" is-arguments "^1.0.4" is-generator-function "^1.0.7" is-typed-array "^1.1.3" safe-buffer "^5.1.2" which-typed-array "^1.1.2" uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== uuid@^8.3.0: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== v8-compile-cache@^2.0.3: version "2.2.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz#9471efa3ef9128d2f7c6a7ca39c4dd6b5055b132" integrity sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q== v8-to-istanbul@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-7.1.0.tgz#5b95cef45c0f83217ec79f8fc7ee1c8b486aee07" integrity sha512-uXUVqNUCLa0AH1vuVxzi+MI4RfxEOKt9pBgKwHbgH7st8Kv2P1m+jvWNnektzBh5QShF3ODgKmUFCf38LnVz1g== dependencies: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" source-map "^0.7.3" validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== dependencies: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" verror@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= dependencies: assert-plus "^1.0.0" core-util-is "1.0.2" extsprintf "^1.2.0" vm-browserify@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== void-elements@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= vue-docgen-api@^3.22.0: version "3.26.0" resolved "https://registry.yarnpkg.com/vue-docgen-api/-/vue-docgen-api-3.26.0.tgz#2afc6a39e72862fbbc60ceb8510c681749f05460" integrity sha512-ujdg4i5ZI/wE46RZQMFzKnDGyhEuPCu+fMA86CAd9EIek/6+OqraSVBm5ZkLrbEd5f8xxdnqMU4yiSGHHeao/Q== dependencies: "@babel/parser" "^7.2.3" "@babel/types" "^7.0.0" ast-types "^0.12.2" hash-sum "^1.0.2" lru-cache "^4.1.5" pug "^2.0.3" recast "^0.17.3" ts-map "^1.0.3" typescript "^3.2.2" vue-template-compiler "^2.0.0" vue-template-compiler@^2.0.0: version "2.6.12" resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.12.tgz#947ed7196744c8a5285ebe1233fe960437fcc57e" integrity sha512-OzzZ52zS41YUbkCBfdXShQTe69j1gQDZ9HIX8miuC9C3rBCk9wIRjLiZZLrmX9V+Ftq/YEyv1JaVr5Y/hNtByg== dependencies: de-indent "^1.0.2" he "^1.1.0" vue2-ace-editor@^0.0.13: version "0.0.13" resolved "https://registry.yarnpkg.com/vue2-ace-editor/-/vue2-ace-editor-0.0.13.tgz#5528998ce2c13e8ed3a294f714298199fd107dc2" integrity sha512-uQICyvJzYNix16xeYjNAINuNUQhPbqMR7UQsJeI+ycpEd2otsiNNU73jcZqHkpjuz0uaHDHnrpzQuI/RApsKXA== dependencies: brace "^0.11.0" w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" integrity sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ== dependencies: browser-process-hrtime "^1.0.0" w3c-xmlserializer@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz#3e7104a05b75146cc60f564380b7f683acf1020a" integrity sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA== dependencies: xml-name-validator "^3.0.0" walker@^1.0.7, walker@~1.0.5: version "1.0.7" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" integrity sha1-L3+bj9ENZ3JisYqITijRlhjgKPs= dependencies: makeerror "1.0.x" webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== webidl-conversions@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== webidl-conversions@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== whatwg-encoding@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== dependencies: iconv-lite "0.4.24" whatwg-mimetype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== whatwg-url@^8.0.0, whatwg-url@^8.1.0: version "8.4.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.4.0.tgz#50fb9615b05469591d2b2bd6dfaed2942ed72837" integrity sha512-vwTUFf6V4zhcPkWp/4CQPr1TW9Ml6SF4lVyaIMBdJw5i6qUUJ1QWM4Z6YYVkfka0OUIzVo/0aNtGVGk256IKWw== dependencies: lodash.sortby "^4.7.0" tr46 "^2.0.2" webidl-conversions "^6.1.0" which-boxed-primitive@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== dependencies: is-bigint "^1.0.1" is-boolean-object "^1.1.0" is-number-object "^1.0.4" is-string "^1.0.5" is-symbol "^1.0.3" which-collection@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.1.tgz#70eab71ebbbd2aefaf32f917082fc62cdcb70906" integrity sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A== dependencies: is-map "^2.0.1" is-set "^2.0.1" is-weakmap "^2.0.1" is-weakset "^2.0.1" which-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= which-typed-array@^1.1.2: version "1.1.4" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.4.tgz#8fcb7d3ee5adf2d771066fba7cf37e32fe8711ff" integrity sha512-49E0SpUe90cjpoc7BOJwyPHRqSAd12c10Qm2amdEZrJPCY2NDxaW01zHITrem+rnETY3dwrbH3UUrUwagfCYDA== dependencies: available-typed-arrays "^1.0.2" call-bind "^1.0.0" es-abstract "^1.18.0-next.1" foreach "^2.0.5" function-bind "^1.1.1" has-symbols "^1.0.1" is-typed-array "^1.1.3" which@^1.2.9: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== dependencies: isexe "^2.0.0" which@^2.0.1, which@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== dependencies: isexe "^2.0.0" window-size@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" integrity sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0= with@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/with/-/with-5.1.1.tgz#fa4daa92daf32c4ea94ed453c81f04686b575dfe" integrity sha1-+k2qktrzLE6pTtRTyB8EaGtXXf4= dependencies: acorn "^3.1.0" acorn-globals "^3.0.0" word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== wordwrap@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" integrity sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8= wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== dependencies: ansi-styles "^4.0.0" string-width "^4.1.0" strip-ansi "^6.0.0" wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= write-file-atomic@^3.0.0: version "3.0.3" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== dependencies: imurmurhash "^0.1.4" is-typedarray "^1.0.0" signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" write@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3" integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== dependencies: mkdirp "^0.5.1" ws@^7.2.3: version "7.4.2" resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.2.tgz#782100048e54eb36fe9843363ab1c68672b261dd" integrity sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA== xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== xmlcreate@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/xmlcreate/-/xmlcreate-2.0.3.tgz#df9ecd518fd3890ab3548e1b811d040614993497" integrity sha512-HgS+X6zAztGa9zIK3Y3LXuJes33Lz9x+YyTxgrkIdabu2vqcGOWwdfCpf1hWLRrd553wd4QCDf6BBO6FfdsRiQ== xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== y18n@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4" integrity sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ== yallist@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= yallist@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" yargs@^15.4.1: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== dependencies: cliui "^6.0.0" decamelize "^1.2.0" find-up "^4.1.0" get-caller-file "^2.0.1" require-directory "^2.1.1" require-main-filename "^2.0.0" set-blocking "^2.0.0" string-width "^4.2.0" which-module "^2.0.0" y18n "^4.0.0" yargs-parser "^18.1.2" yargs@~3.10.0: version "3.10.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" integrity sha1-9+572FfdfB0tOMDnTvvWgdFDH9E= dependencies: camelcase "^1.0.2" cliui "^2.1.0" decamelize "^1.0.0" window-size "0.1.0"