1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-26 17:03:12 +03:00

Merge branch 'develop' into t3chguy/unhomoglyph

This commit is contained in:
J. Ryan Stinnett
2018-12-18 01:01:41 +00:00
committed by GitHub
71 changed files with 13914 additions and 903 deletions

View File

@@ -1,5 +1,5 @@
language: node_js language: node_js
node_js: node_js:
- node # Latest stable version of nodejs. - "10.11.0"
script: script:
- ./travis.sh - ./travis.sh

View File

@@ -1,3 +1,329 @@
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) 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) [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.6-rc.1...v0.10.6)

View File

@@ -30,9 +30,61 @@ In Node.js
console.log("Public Rooms: %s", JSON.stringify(data)); console.log("Public Rooms: %s", JSON.stringify(data));
}); });
``` ```
See below for how to include libolm to enable end-to-end-encryption. Please check See below for how to include libolm to enable end-to-end-encryption. Please check
[the Node.js terminal app](examples/node) for a more complex example. [the Node.js terminal app](examples/node) for a more complex example.
To start the client:
```javascript
await client.startClient({initialSyncLimit: 10});
```
You can perform a call to `/sync` to get the current state of the client:
```javascript
client.once('sync', function(state, prevState, res) {
if(state === 'PREPARED') {
console.log("prepared");
} else {
console.log(state);
process.exit(1);
}
});
```
To send a message:
```javascript
var content = {
"body": "message text",
"msgtype": "m.text"
};
client.sendEvent("roomId", "m.room.message", content, "", (err, res) => {
console.log(err);
});
```
To listen for message events:
```javascript
client.on("Room.timeline", function(event, room, toStartOfTimeline) {
if (event.getType() !== "m.room.message") {
return; // only use messages
}
console.log(event.event.content.body);
});
```
By default, the `matrix-js-sdk` client uses the `MatrixInMemoryStore` to store events as they are received. For example to iterate through the currently stored timeline for a room:
```javascript
Object.keys(client.store.rooms).forEach((roomId) => {
client.getRoom(roomId).timeline.forEach(t => {
console.log(t.event);
});
});
```
What does this SDK do? What does this SDK do?
---------------------- ----------------------
@@ -267,13 +319,13 @@ To provide the Olm library in a browser application:
To provide the Olm library in a node.js application: To provide the Olm library in a node.js application:
* ``npm install https://matrix.org/packages/npm/olm/olm-2.2.2.tgz`` * ``npm install https://matrix.org/packages/npm/olm/olm-3.0.0.tgz``
(replace the URL with the latest version you want to use from (replace the URL with the latest version you want to use from
https://matrix.org/packages/npm/olm/) https://matrix.org/packages/npm/olm/)
* ``global.Olm = require('olm');`` *before* loading ``matrix-js-sdk``. * ``global.Olm = require('olm');`` *before* loading ``matrix-js-sdk``.
If you want to package Olm as dependency for your node.js application, you If you want to package Olm as dependency for your node.js application, you
can use ``npm install https://matrix.org/packages/npm/olm/olm-2.2.2.tgz can use ``npm install https://matrix.org/packages/npm/olm/olm-3.0.0.tgz
--save-optional`` (if your application also works without e2e crypto enabled) --save-optional`` (if your application also works without e2e crypto enabled)
or ``--save`` (if it doesn't) to do so. or ``--save`` (if it doesn't) to do so.

View File

@@ -1,5 +1,17 @@
var matrixcs = require("./lib/matrix"); var matrixcs = require("./lib/matrix");
matrixcs.request(require("browser-request")); const request = require('browser-request');
const queryString = require('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 // just *accessing* indexedDB throws an exception in firefox with
// indexeddb disabled. // indexeddb disabled.

View File

@@ -202,9 +202,9 @@ function printRoomList() {
dateStr = new Date(msg.getTs()).toISOString().replace( dateStr = new Date(msg.getTs()).toISOString().replace(
/T/, ' ').replace(/\..+/, ''); /T/, ' ').replace(/\..+/, '');
} }
var me = roomList[i].getMember(myUserId); var myMembership = roomList[i].getMyMembership();
if (me) { if (myMembership) {
fmt = fmts[me.membership]; fmt = fmts[myMembership];
} }
var roomName = fixWidth(roomList[i].name, 25); var roomName = fixWidth(roomList[i].name, 25);
print( print(

View File

@@ -5,7 +5,7 @@ set -x
export NVM_DIR="$HOME/.nvm" export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
nvm use 6 || exit $? nvm use 10 || exit $?
npm install || exit $? npm install || exit $?
RC=0 RC=0

7124
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "matrix-js-sdk", "name": "matrix-js-sdk",
"version": "0.10.6", "version": "0.14.2",
"description": "Matrix Client-Server SDK for Javascript", "description": "Matrix Client-Server SDK for Javascript",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -10,7 +10,9 @@
"test": "npm run test:build && npm run test:run", "test": "npm run test:build && npm run test:run",
"check": "npm run test:build && _mocha --recursive specbuild --colors", "check": "npm run test:build && _mocha --recursive specbuild --colors",
"gendoc": "babel --no-babelrc -d .jsdocbuild src && jsdoc -r .jsdocbuild -P package.json -R README.md -d .jsdoc", "gendoc": "babel --no-babelrc -d .jsdocbuild src && jsdoc -r .jsdocbuild -P package.json -R README.md -d .jsdoc",
"start": "babel -s -w -d lib src", "start": "npm run start:init && npm run start:watch",
"start:watch": "babel -s -w --skip-initial-build -d lib src",
"start:init": "babel -s -d lib src",
"clean": "rimraf lib dist", "clean": "rimraf lib dist",
"build": "babel -s -d lib src && rimraf dist && mkdir dist && browserify -d browser-index.js | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js && uglifyjs -c -m -o dist/browser-matrix.min.js --source-map dist/browser-matrix.min.js.map --in-source-map dist/browser-matrix.js.map dist/browser-matrix.js", "build": "babel -s -d lib src && rimraf dist && mkdir dist && browserify -d browser-index.js | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js && uglifyjs -c -m -o dist/browser-matrix.min.js --source-map dist/browser-matrix.min.js.map --in-source-map dist/browser-matrix.js.map dist/browser-matrix.js",
"dist": "npm run build", "dist": "npm run build",
@@ -19,6 +21,7 @@
"prepublish": "npm run clean && npm run build && git rev-parse HEAD > git-revision.txt" "prepublish": "npm run clean && npm run build && git rev-parse HEAD > git-revision.txt"
}, },
"repository": { "repository": {
"type": "git",
"url": "https://github.com/matrix-org/matrix-js-sdk" "url": "https://github.com/matrix-org/matrix-js-sdk"
}, },
"keywords": [ "keywords": [
@@ -53,17 +56,20 @@
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"bluebird": "^3.5.0", "bluebird": "^3.5.0",
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
"bs58": "^4.0.1",
"content-type": "^1.0.2", "content-type": "^1.0.2",
"request": "^2.53.0", "loglevel": "1.6.1",
"qs": "^6.5.2",
"request": "^2.88.0",
"unhomoglyph": "^1.0.2" "unhomoglyph": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"babel-cli": "^6.18.0", "babel-cli": "^6.18.0",
"babel-eslint": "^7.1.1", "babel-eslint": "^8.1.1",
"babel-plugin-transform-async-to-bluebird": "^1.1.1", "babel-plugin-transform-async-to-bluebird": "^1.1.1",
"babel-plugin-transform-runtime": "^6.23.0", "babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-es2015": "^6.18.0", "babel-preset-es2015": "^6.18.0",
"browserify": "^14.0.0", "browserify": "^16.2.3",
"browserify-shim": "^3.8.13", "browserify-shim": "^3.8.13",
"eslint": "^3.13.1", "eslint": "^3.13.1",
"eslint-config-google": "^0.7.1", "eslint-config-google": "^0.7.1",
@@ -72,14 +78,14 @@
"istanbul": "^0.4.5", "istanbul": "^0.4.5",
"jsdoc": "^3.5.5", "jsdoc": "^3.5.5",
"lolex": "^1.5.2", "lolex": "^1.5.2",
"matrix-mock-request": "^1.2.0", "matrix-mock-request": "^1.2.2",
"mocha": "^3.2.0", "mocha": "^5.2.0",
"mocha-jenkins-reporter": "^0.3.6", "mocha-jenkins-reporter": "^0.4.0",
"rimraf": "^2.5.4", "rimraf": "^2.5.4",
"source-map-support": "^0.4.11", "source-map-support": "^0.4.11",
"sourceify": "^0.1.0", "sourceify": "^0.1.0",
"uglify-js": "^2.8.26", "uglify-js": "^2.8.26",
"watchify": "^3.2.1" "watchify": "^3.11.0"
}, },
"browserify": { "browserify": {
"transform": [ "transform": [

View File

@@ -11,7 +11,17 @@
set -e set -e
jq --version > /dev/null || (echo "jq is required: please install it"; kill $$) jq --version > /dev/null || (echo "jq is required: please install it"; kill $$)
hub --version > /dev/null || (echo "hub 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
USAGE="$0 [-xz] [-c changelog_file] vX.Y.Z" USAGE="$0 [-xz] [-c changelog_file] vX.Y.Z"
@@ -45,7 +55,8 @@ fi
skip_changelog= skip_changelog=
skip_jsdoc= skip_jsdoc=
changelog_file="CHANGELOG.md" changelog_file="CHANGELOG.md"
while getopts hc:xz f; do expected_npm_user="matrixdotorg"
while getopts hc:u:xz f; do
case $f in case $f in
h) h)
help help
@@ -60,6 +71,9 @@ while getopts hc:xz f; do
z) z)
skip_jsdoc=1 skip_jsdoc=1
;; ;;
u)
expected_npm_user="$OPTARG"
;;
esac esac
done done
shift `expr $OPTIND - 1` shift `expr $OPTIND - 1`
@@ -74,6 +88,12 @@ if [ -z "$skip_changelog" ]; then
update_changelog -h > /dev/null || (echo "github-changelog-generator is required: please install it"; exit) update_changelog -h > /dev/null || (echo "github-changelog-generator is required: please install it"; exit)
fi fi
actual_npm_user=`npm whoami`;
if [ $expected_npm_user != $actual_npm_user ]; then
echo "you need to be logged into npm as $expected_npm_user, but you are logged in as $actual_npm_user" >&2
exit 1
fi
# ignore leading v on release # ignore leading v on release
release="${1#v}" release="${1#v}"
tag="v${release}" tag="v${release}"
@@ -245,7 +265,7 @@ release_text=`mktemp`
echo "$tag" > "${release_text}" echo "$tag" > "${release_text}"
echo >> "${release_text}" echo >> "${release_text}"
cat "${latest_changes}" >> "${release_text}" cat "${latest_changes}" >> "${release_text}"
hub release create $hubflags $assets -f "${release_text}" "$tag" hub release create $hubflags $assets -F "${release_text}" "$tag"
if [ $dodist -eq 0 ]; then if [ $dodist -eq 0 ]; then
rm -rf "$builddir" rm -rf "$builddir"
@@ -281,7 +301,7 @@ fi
echo "updating master branch" echo "updating master branch"
git checkout master git checkout master
git pull git pull
git merge --ff-only "$rel_branch" git merge "$rel_branch"
# push master and docs (if generated) to github # push master and docs (if generated) to github
git push origin master git push origin master

View File

@@ -102,9 +102,11 @@ TestClient.prototype.start = function() {
/** /**
* stop the client * stop the client
* @return {Promise} Resolves once the mock http backend has finished all pending flushes
*/ */
TestClient.prototype.stop = function() { TestClient.prototype.stop = function() {
this.client.stopClient(); this.client.stopClient();
return this.httpBackend.stop();
}; };
/** /**

View File

@@ -97,7 +97,7 @@ describe("DeviceList management:", function() {
}); });
afterEach(function() { afterEach(function() {
aliceTestClient.stop(); return aliceTestClient.stop();
}); });
it("Alice shouldn't do a second /query for non-e2e-capable devices", function() { it("Alice shouldn't do a second /query for non-e2e-capable devices", function() {

View File

@@ -410,10 +410,10 @@ describe("MatrixClient crypto", function() {
}); });
afterEach(function() { afterEach(function() {
aliTestClient.stop();
aliTestClient.httpBackend.verifyNoOutstandingExpectation(); aliTestClient.httpBackend.verifyNoOutstandingExpectation();
bobTestClient.stop();
bobTestClient.httpBackend.verifyNoOutstandingExpectation(); bobTestClient.httpBackend.verifyNoOutstandingExpectation();
return Promise.all([aliTestClient.stop(), bobTestClient.stop()]);
}); });
it("Bob uploads device keys", function() { it("Bob uploads device keys", function() {

View File

@@ -30,6 +30,7 @@ describe("MatrixClient events", function() {
afterEach(function() { afterEach(function() {
httpBackend.verifyNoOutstandingExpectation(); httpBackend.verifyNoOutstandingExpectation();
client.stopClient(); client.stopClient();
return httpBackend.stop();
}); });
describe("emissions", function() { describe("emissions", function() {

View File

@@ -111,6 +111,7 @@ describe("getEventTimeline support", function() {
if (client) { if (client) {
client.stopClient(); client.stopClient();
} }
return httpBackend.stop();
}); });
it("timeline support must be enabled to work", function(done) { it("timeline support must be enabled to work", function(done) {

View File

@@ -41,6 +41,7 @@ describe("MatrixClient", function() {
afterEach(function() { afterEach(function() {
httpBackend.verifyNoOutstandingExpectation(); httpBackend.verifyNoOutstandingExpectation();
return httpBackend.stop();
}); });
describe("uploadContent", function() { describe("uploadContent", function() {
@@ -159,7 +160,7 @@ describe("MatrixClient", function() {
describe("joinRoom", function() { describe("joinRoom", function() {
it("should no-op if you've already joined a room", function() { it("should no-op if you've already joined a room", function() {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
const room = new Room(roomId); const room = new Room(roomId, userId);
room.addLiveEvents([ room.addLiveEvents([
utils.mkMembership({ utils.mkMembership({
user: userId, room: roomId, mship: "join", event: true, user: userId, room: roomId, mship: "join", event: true,

View File

@@ -64,6 +64,7 @@ describe("MatrixClient opts", function() {
afterEach(function() { afterEach(function() {
httpBackend.verifyNoOutstandingExpectation(); httpBackend.verifyNoOutstandingExpectation();
return httpBackend.stop();
}); });
describe("without opts.store", function() { describe("without opts.store", function() {
@@ -94,7 +95,7 @@ describe("MatrixClient opts", function() {
httpBackend.flush("/txn1", 1); httpBackend.flush("/txn1", 1);
}); });
it("should be able to sync / get new events", function(done) { it("should be able to sync / get new events", async function() {
const expectedEventTypes = [ // from /initialSync const expectedEventTypes = [ // from /initialSync
"m.room.message", "m.room.name", "m.room.member", "m.room.member", "m.room.message", "m.room.name", "m.room.member", "m.room.member",
"m.room.create", "m.room.create",
@@ -110,20 +111,16 @@ describe("MatrixClient opts", function() {
httpBackend.when("GET", "/pushrules").respond(200, {}); httpBackend.when("GET", "/pushrules").respond(200, {});
httpBackend.when("POST", "/filter").respond(200, { filter_id: "foo" }); httpBackend.when("POST", "/filter").respond(200, { filter_id: "foo" });
httpBackend.when("GET", "/sync").respond(200, syncData); httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient(); await client.startClient();
httpBackend.flush("/pushrules", 1).then(function() { await httpBackend.flush("/pushrules", 1);
return httpBackend.flush("/filter", 1); await httpBackend.flush("/filter", 1);
}).then(function() { await Promise.all([
return Promise.all([
httpBackend.flush("/sync", 1), httpBackend.flush("/sync", 1),
utils.syncPromise(client), utils.syncPromise(client),
]); ]);
}).done(function() {
expect(expectedEventTypes.length).toEqual( expect(expectedEventTypes.length).toEqual(
0, "Expected to see event types: " + expectedEventTypes, 0, "Expected to see event types: " + expectedEventTypes,
); );
done();
});
}); });
}); });

View File

@@ -36,6 +36,7 @@ describe("MatrixClient retrying", function() {
afterEach(function() { afterEach(function() {
httpBackend.verifyNoOutstandingExpectation(); httpBackend.verifyNoOutstandingExpectation();
return httpBackend.stop();
}); });
xit("should retry according to MatrixScheduler.retryFn", function() { xit("should retry according to MatrixScheduler.retryFn", function() {

View File

@@ -130,6 +130,7 @@ describe("MatrixClient room timelines", function() {
afterEach(function() { afterEach(function() {
httpBackend.verifyNoOutstandingExpectation(); httpBackend.verifyNoOutstandingExpectation();
client.stopClient(); client.stopClient();
return httpBackend.stop();
}); });
describe("local echo events", function() { describe("local echo events", function() {

View File

@@ -38,6 +38,7 @@ describe("MatrixClient syncing", function() {
afterEach(function() { afterEach(function() {
httpBackend.verifyNoOutstandingExpectation(); httpBackend.verifyNoOutstandingExpectation();
client.stopClient(); client.stopClient();
return httpBackend.stop();
}); });
describe("startClient", function() { describe("startClient", function() {

View File

@@ -296,7 +296,7 @@ describe("megolm", function() {
}); });
afterEach(function() { afterEach(function() {
aliceTestClient.stop(); return aliceTestClient.stop();
}); });
it("Alice receives a megolm message", function() { it("Alice receives a megolm message", function() {
@@ -817,8 +817,14 @@ describe("megolm", function() {
}; };
}); });
// 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([ return Promise.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), aliceTestClient.client.resendEvent(unsentEvent, room),
// the crypto stuff can take a while, so give the requests a whole second. // the crypto stuff can take a while, so give the requests a whole second.
aliceTestClient.httpBackend.flushAllExpected({ aliceTestClient.httpBackend.flushAllExpected({

View File

@@ -0,0 +1,670 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
"use strict";
import 'source-map-support/register';
import Promise from 'bluebird';
const sdk = require("../..");
const utils = require("../test-utils");
const AutoDiscovery = sdk.AutoDiscovery;
import expect from 'expect';
import MockHttpBackend from "matrix-mock-request";
describe("AutoDiscovery", function() {
let httpBackend = null;
beforeEach(function() {
utils.beforeEach(this); // eslint-disable-line no-invalid-this
httpBackend = new MockHttpBackend();
sdk.request(httpBackend.requestFn);
});
it("should throw an error when no domain is specified", function() {
return Promise.all([
AutoDiscovery.findClientConfig(/* no args */).then(() => {
throw new Error("Expected a failure, not success with no args");
}, () => {
return true;
}),
AutoDiscovery.findClientConfig("").then(() => {
throw new Error("Expected a failure, not success with an empty string");
}, () => {
return true;
}),
AutoDiscovery.findClientConfig(null).then(() => {
throw new Error("Expected a failure, not success with null");
}, () => {
return true;
}),
AutoDiscovery.findClientConfig(true).then(() => {
throw new Error("Expected a failure, not success with a non-string");
}, () => {
return true;
}),
]);
});
it("should return PROMPT when .well-known 404s", function() {
httpBackend.when("GET", "/.well-known/matrix/client").respond(404, {});
return Promise.all([
httpBackend.flushAllExpected(),
AutoDiscovery.findClientConfig("example.org").then((conf) => {
const expected = {
"m.homeserver": {
state: "PROMPT",
error: null,
base_url: null,
},
"m.identity_server": {
state: "PROMPT",
error: null,
base_url: null,
},
};
expect(conf).toEqual(expected);
}),
]);
});
it("should return FAIL_PROMPT when .well-known returns a 500 error", function() {
httpBackend.when("GET", "/.well-known/matrix/client").respond(500, {});
return Promise.all([
httpBackend.flushAllExpected(),
AutoDiscovery.findClientConfig("example.org").then((conf) => {
const expected = {
"m.homeserver": {
state: "FAIL_PROMPT",
error: "Invalid homeserver discovery response",
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: "Invalid homeserver discovery response",
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: "Invalid homeserver discovery response",
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: "Invalid homeserver discovery response",
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: "Invalid homeserver discovery response",
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: "Invalid homeserver discovery response",
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: "Invalid homeserver discovery response",
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: "Invalid homeserver discovery response",
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: 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: "Invalid homeserver discovery response",
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: 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: "Invalid homeserver discovery response",
base_url: null,
},
"m.identity_server": {
state: "PROMPT",
error: null,
base_url: null,
},
};
expect(conf).toEqual(expected);
}),
]);
});
it("should return SUCCESS when .well-known has a verifiably accurate base_url for " +
"m.homeserver", function() {
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri).toEqual("https://example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
base_url: "https://example.org",
},
});
return Promise.all([
httpBackend.flushAllExpected(),
AutoDiscovery.findClientConfig("example.org").then((conf) => {
const expected = {
"m.homeserver": {
state: "SUCCESS",
error: null,
base_url: "https://example.org",
},
"m.identity_server": {
state: "PROMPT",
error: null,
base_url: null,
},
};
expect(conf).toEqual(expected);
}),
]);
});
it("should return SUCCESS with the right homeserver URL", function() {
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
});
return Promise.all([
httpBackend.flushAllExpected(),
AutoDiscovery.findClientConfig("example.org").then((conf) => {
const expected = {
"m.homeserver": {
state: "SUCCESS",
error: null,
base_url: "https://chat.example.org",
},
"m.identity_server": {
state: "PROMPT",
error: null,
base_url: null,
},
};
expect(conf).toEqual(expected);
}),
]);
});
it("should return FAIL_ERROR when the identity server configuration is wrong " +
"(missing base_url)", function() {
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
"m.identity_server": {
not_base_url: "https://identity.example.org",
},
});
return Promise.all([
httpBackend.flushAllExpected(),
AutoDiscovery.findClientConfig("example.org").then((conf) => {
const expected = {
"m.homeserver": {
state: "FAIL_ERROR",
error: "Invalid identity server discovery response",
// We still expect the base_url to be here for debugging purposes.
base_url: "https://chat.example.org",
},
"m.identity_server": {
state: "FAIL_ERROR",
error: "Invalid identity server discovery response",
base_url: null,
},
};
expect(conf).toEqual(expected);
}),
]);
});
it("should return FAIL_ERROR when the identity server configuration is wrong " +
"(empty base_url)", function() {
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
"m.identity_server": {
base_url: "",
},
});
return Promise.all([
httpBackend.flushAllExpected(),
AutoDiscovery.findClientConfig("example.org").then((conf) => {
const expected = {
"m.homeserver": {
state: "FAIL_ERROR",
error: "Invalid identity server discovery response",
// We still expect the base_url to be here for debugging purposes.
base_url: "https://chat.example.org",
},
"m.identity_server": {
state: "FAIL_ERROR",
error: "Invalid identity server discovery response",
base_url: null,
},
};
expect(conf).toEqual(expected);
}),
]);
});
it("should return FAIL_ERROR when the identity server configuration is wrong " +
"(validation error: 404)", function() {
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/_matrix/identity/api/v1").respond(404, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
"m.identity_server": {
base_url: "https://identity.example.org",
},
});
return Promise.all([
httpBackend.flushAllExpected(),
AutoDiscovery.findClientConfig("example.org").then((conf) => {
const expected = {
"m.homeserver": {
state: "FAIL_ERROR",
error: "Invalid identity server discovery response",
// We still expect the base_url to be here for debugging purposes.
base_url: "https://chat.example.org",
},
"m.identity_server": {
state: "FAIL_ERROR",
error: "Invalid identity server discovery response",
base_url: null,
},
};
expect(conf).toEqual(expected);
}),
]);
});
it("should return FAIL_ERROR when the identity server configuration is wrong " +
"(validation error: 500)", function() {
httpBackend.when("GET", "/_matrix/client/versions").check((req) => {
expect(req.opts.uri)
.toEqual("https://chat.example.org/_matrix/client/versions");
}).respond(200, {
versions: ["r0.0.1"],
});
httpBackend.when("GET", "/_matrix/identity/api/v1").respond(500, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
"m.identity_server": {
base_url: "https://identity.example.org",
},
});
return Promise.all([
httpBackend.flushAllExpected(),
AutoDiscovery.findClientConfig("example.org").then((conf) => {
const expected = {
"m.homeserver": {
state: "FAIL_ERROR",
error: "Invalid identity server discovery response",
// We still expect the base_url to be here for debugging purposes
base_url: "https://chat.example.org",
},
"m.identity_server": {
state: "FAIL_ERROR",
error: "Invalid identity server discovery response",
base_url: null,
},
};
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);
}),
]);
});
});

View File

@@ -1,20 +1,122 @@
"use strict";
import 'source-map-support/register'; import 'source-map-support/register';
const sdk = require("../.."); import '../olm-loader';
let Crypto;
if (sdk.CRYPTO_ENABLED) {
Crypto = require("../../lib/crypto");
}
import Crypto from '../../lib/crypto';
import expect from 'expect'; import expect from 'expect';
import WebStorageSessionStore from '../../lib/store/session/webstorage';
import MemoryCryptoStore from '../../lib/crypto/store/memory-crypto-store.js';
import MockStorageApi from '../MockStorageApi';
const EventEmitter = require("events").EventEmitter;
const sdk = require("../..");
const Olm = global.Olm;
describe("Crypto", function() { describe("Crypto", function() {
if (!sdk.CRYPTO_ENABLED) { if (!sdk.CRYPTO_ENABLED) {
return; return;
} }
beforeEach(function(done) {
Olm.init().then(done);
});
it("Crypto exposes the correct olm library version", function() { it("Crypto exposes the correct olm library version", function() {
expect(Crypto.getOlmVersion()[0]).toEqual(2); expect(Crypto.getOlmVersion()[0]).toEqual(3);
});
describe('Session management', function() {
const otkResponse = {
one_time_keys: {
'@alice:home.server': {
aliceDevice: {
'signed_curve25519:FLIBBLE': {
key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI',
signatures: {
'@alice:home.server': {
'ed25519:aliceDevice': 'totally a valid signature',
},
},
},
},
},
},
};
let crypto;
let mockBaseApis;
let mockRoomList;
let fakeEmitter;
beforeEach(async function() {
const mockStorage = new MockStorageApi();
const sessionStore = new WebStorageSessionStore(mockStorage);
const cryptoStore = new MemoryCryptoStore(mockStorage);
cryptoStore.storeEndToEndDeviceData({
devices: {
'@bob:home.server': {
'BOBDEVICE': {
keys: {
'curve25519:BOBDEVICE': 'this is a key',
},
},
},
},
trackingStatus: {},
});
mockBaseApis = {
sendToDevice: expect.createSpy(),
getKeyBackupVersion: expect.createSpy(),
isGuest: expect.createSpy(),
};
mockRoomList = {};
fakeEmitter = new EventEmitter();
crypto = new Crypto(
mockBaseApis,
sessionStore,
"@alice:home.server",
"FLIBBLE",
sessionStore,
cryptoStore,
mockRoomList,
);
crypto.registerEventHandlers(fakeEmitter);
await crypto.init();
});
afterEach(async function() {
await crypto.stop();
});
it("restarts wedged Olm sessions", async function() {
const prom = new Promise((resolve) => {
mockBaseApis.claimOneTimeKeys = function() {
resolve();
return otkResponse;
};
});
fakeEmitter.emit('toDeviceEvent', {
getType: expect.createSpy().andReturn('m.room.message'),
getContent: expect.createSpy().andReturn({
msgtype: 'm.bad.encrypted',
}),
getWireContent: expect.createSpy().andReturn({
algorithm: 'm.olm.v1.curve25519-aes-sha2',
sender_key: 'this is a key',
}),
getSender: expect.createSpy().andReturn('@bob:home.server'),
});
await prom;
});
}); });
}); });

View File

@@ -59,16 +59,25 @@ describe('DeviceList', function() {
let downloadSpy; let downloadSpy;
let sessionStore; let sessionStore;
let cryptoStore; let cryptoStore;
let deviceLists = [];
beforeEach(function() { beforeEach(function() {
testUtils.beforeEach(this); // eslint-disable-line no-invalid-this testUtils.beforeEach(this); // eslint-disable-line no-invalid-this
deviceLists = [];
downloadSpy = expect.createSpy(); downloadSpy = expect.createSpy();
const mockStorage = new MockStorageApi(); const mockStorage = new MockStorageApi();
sessionStore = new WebStorageSessionStore(mockStorage); sessionStore = new WebStorageSessionStore(mockStorage);
cryptoStore = new MemoryCryptoStore(); cryptoStore = new MemoryCryptoStore();
}); });
afterEach(function() {
for (const dl of deviceLists) {
dl.stop();
}
});
function createTestDeviceList() { function createTestDeviceList() {
const baseApis = { const baseApis = {
downloadKeysForUsers: downloadSpy, downloadKeysForUsers: downloadSpy,
@@ -76,7 +85,9 @@ describe('DeviceList', function() {
const mockOlm = { const mockOlm = {
verifySignature: function(key, message, signature) {}, verifySignature: function(key, message, signature) {},
}; };
return new DeviceList(baseApis, cryptoStore, sessionStore, mockOlm); const dl = new DeviceList(baseApis, cryptoStore, sessionStore, mockOlm);
deviceLists.push(dl);
return dl;
} }
it("should successfully download and store device keys", function() { it("should successfully download and store device keys", function() {

View File

@@ -1,8 +1,4 @@
try { import '../../../olm-loader';
global.Olm = require('olm');
} catch (e) {
console.warn("unable to run megolm tests: libolm not available");
}
import expect from 'expect'; import expect from 'expect';
import Promise from 'bluebird'; import Promise from 'bluebird';
@@ -13,20 +9,17 @@ import WebStorageSessionStore from '../../../../lib/store/session/webstorage';
import MemoryCryptoStore from '../../../../lib/crypto/store/memory-crypto-store.js'; import MemoryCryptoStore from '../../../../lib/crypto/store/memory-crypto-store.js';
import MockStorageApi from '../../../MockStorageApi'; import MockStorageApi from '../../../MockStorageApi';
import testUtils from '../../../test-utils'; import testUtils from '../../../test-utils';
import OlmDevice from '../../../../lib/crypto/OlmDevice';
// Crypto and OlmDevice won't import unless we have global.Olm import Crypto from '../../../../lib/crypto';
let OlmDevice;
let Crypto;
if (global.Olm) {
OlmDevice = require('../../../../lib/crypto/OlmDevice');
Crypto = require('../../../../lib/crypto');
}
const MatrixEvent = sdk.MatrixEvent; const MatrixEvent = sdk.MatrixEvent;
const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; 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 ROOM_ID = '!ROOM:ID';
const Olm = global.Olm;
describe("MegolmDecryption", function() { describe("MegolmDecryption", function() {
if (!global.Olm) { if (!global.Olm) {
console.warn('Not running megolm unit tests: libolm not present'); console.warn('Not running megolm unit tests: libolm not present');
@@ -38,9 +31,11 @@ describe("MegolmDecryption", function() {
let mockCrypto; let mockCrypto;
let mockBaseApis; let mockBaseApis;
beforeEach(function() { beforeEach(async function() {
testUtils.beforeEach(this); // eslint-disable-line no-invalid-this testUtils.beforeEach(this); // eslint-disable-line no-invalid-this
await Olm.init();
mockCrypto = testUtils.mock(Crypto, 'Crypto'); mockCrypto = testUtils.mock(Crypto, 'Crypto');
mockBaseApis = {}; mockBaseApis = {};
@@ -69,7 +64,7 @@ describe("MegolmDecryption", function() {
describe('receives some keys:', function() { describe('receives some keys:', function() {
let groupSession; let groupSession;
beforeEach(function() { beforeEach(async function() {
groupSession = new global.Olm.OutboundGroupSession(); groupSession = new global.Olm.OutboundGroupSession();
groupSession.create(); groupSession.create();
@@ -98,7 +93,7 @@ describe("MegolmDecryption", function() {
}, },
}; };
return event.attemptDecryption(mockCrypto).then(() => { await event.attemptDecryption(mockCrypto).then(() => {
megolmDecryption.onRoomKeyEvent(event); megolmDecryption.onRoomKeyEvent(event);
}); });
}); });
@@ -266,5 +261,92 @@ describe("MegolmDecryption", function() {
// test is successful if no exception is thrown // test is successful if no exception is thrown
}); });
}); });
it("re-uses sessions for sequential messages", async function() {
const mockStorage = new MockStorageApi();
const sessionStore = new WebStorageSessionStore(mockStorage);
const cryptoStore = new MemoryCryptoStore(mockStorage);
const olmDevice = new OlmDevice(sessionStore, cryptoStore);
olmDevice.verifySignature = expect.createSpy();
await olmDevice.init();
mockBaseApis.claimOneTimeKeys = expect.createSpy().andReturn(Promise.resolve({
one_time_keys: {
'@alice:home.server': {
aliceDevice: {
'signed_curve25519:flooble': {
key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI',
signatures: {
'@alice:home.server': {
'ed25519:aliceDevice': 'totally valid',
},
},
},
},
},
},
}));
mockBaseApis.sendToDevice = expect.createSpy().andReturn(Promise.resolve());
mockCrypto.downloadKeys.andReturn(Promise.resolve({
'@alice:home.server': {
aliceDevice: {
deviceId: 'aliceDevice',
isBlocked: expect.createSpy().andReturn(false),
isUnverified: expect.createSpy().andReturn(false),
getIdentityKey: expect.createSpy().andReturn(
'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE',
),
getFingerprint: expect.createSpy().andReturn(''),
},
},
}));
const megolmEncryption = new MegolmEncryption({
userId: '@user:id',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: mockBaseApis,
roomId: ROOM_ID,
config: {
rotation_period_ms: 9999999999999,
},
});
const mockRoom = {
getEncryptionTargetMembers: expect.createSpy().andReturn(
[{userId: "@alice:home.server"}],
),
getBlacklistUnverifiedDevices: expect.createSpy().andReturn(false),
};
const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", {
body: "Some text",
});
expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled();
// this should have claimed a key for alice as it's starting a new session
expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalled(
[['@alice:home.server', 'aliceDevice']], 'signed_curve25519',
);
expect(mockCrypto.downloadKeys).toHaveBeenCalledWith(
['@alice:home.server'], false,
);
expect(mockBaseApis.sendToDevice).toHaveBeenCalled();
expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalled(
[['@alice:home.server', 'aliceDevice']], 'signed_curve25519',
);
mockBaseApis.claimOneTimeKeys.reset();
const ct2 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", {
body: "Some more text",
});
// this should *not* have claimed a key as it should be using the same session
expect(mockBaseApis.claimOneTimeKeys).toNotHaveBeenCalled();
// likewise they should show the same session ID
expect(ct2.session_id).toEqual(ct1.session_id);
});
}); });
}); });

View File

@@ -0,0 +1,86 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import '../../../olm-loader';
import expect from 'expect';
import WebStorageSessionStore from '../../../../lib/store/session/webstorage';
import MemoryCryptoStore from '../../../../lib/crypto/store/memory-crypto-store.js';
import MockStorageApi from '../../../MockStorageApi';
import testUtils from '../../../test-utils';
import OlmDevice from '../../../../lib/crypto/OlmDevice';
function makeOlmDevice() {
const mockStorage = new MockStorageApi();
const sessionStore = new WebStorageSessionStore(mockStorage);
const cryptoStore = new MemoryCryptoStore(mockStorage);
const olmDevice = new OlmDevice(sessionStore, cryptoStore);
return olmDevice;
}
async function setupSession(initiator, opponent) {
await opponent.generateOneTimeKeys(1);
const keys = await opponent.getOneTimeKeys();
const firstKey = Object.values(keys['curve25519'])[0];
const sid = await initiator.createOutboundSession(
opponent.deviceCurve25519Key, firstKey,
);
return sid;
}
describe("OlmDecryption", function() {
if (!global.Olm) {
console.warn('Not running megolm unit tests: libolm not present');
return;
}
let aliceOlmDevice;
let bobOlmDevice;
beforeEach(async function() {
testUtils.beforeEach(this); // eslint-disable-line no-invalid-this
await global.Olm.init();
aliceOlmDevice = makeOlmDevice();
bobOlmDevice = makeOlmDevice();
await aliceOlmDevice.init();
await bobOlmDevice.init();
});
describe('olm', function() {
it("can decrypt messages", async function() {
const sid = await setupSession(aliceOlmDevice, bobOlmDevice);
const ciphertext = await aliceOlmDevice.encryptMessage(
bobOlmDevice.deviceCurve25519Key,
sid,
"The olm or proteus is an aquatic salamander in the family Proteidae",
);
const result = await bobOlmDevice.createInboundSession(
aliceOlmDevice.deviceCurve25519Key,
ciphertext.type,
ciphertext.body,
);
expect(result.payload).toEqual(
"The olm or proteus is an aquatic salamander in the family Proteidae",
);
});
});
});

View File

@@ -0,0 +1,467 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import '../../olm-loader';
import expect from 'expect';
import Promise from 'bluebird';
import sdk from '../../..';
import algorithms from '../../../lib/crypto/algorithms';
import WebStorageSessionStore from '../../../lib/store/session/webstorage';
import MemoryCryptoStore from '../../../lib/crypto/store/memory-crypto-store.js';
import MockStorageApi from '../../MockStorageApi';
import testUtils from '../../test-utils';
import OlmDevice from '../../../lib/crypto/OlmDevice';
import Crypto from '../../../lib/crypto';
const Olm = global.Olm;
const MatrixClient = sdk.MatrixClient;
const MatrixEvent = sdk.MatrixEvent;
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',
},
};
function makeTestClient(sessionStore, cryptoStore) {
const scheduler = [
"getQueueForEvent", "queueEvent", "removeEventFromQueue",
"setProcessFunction",
].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {});
const store = [
"getRoom", "getRooms", "getUser", "getSyncToken", "scrollback",
"save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom",
"storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter",
"storeFilter", "getSyncAccumulator", "startup", "deleteAllData",
].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {});
store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null));
store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null));
store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null));
return new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: "https://identity.server",
accessToken: "my.access.token",
request: function() {}, // NOP
store: store,
scheduler: scheduler,
userId: "@alice:bar",
deviceId: "device",
sessionStore: sessionStore,
cryptoStore: cryptoStore,
});
}
describe("MegolmBackup", function() {
if (!global.Olm) {
console.warn('Not running megolm backup unit tests: libolm not present');
return;
}
let olmDevice;
let mockOlmLib;
let mockCrypto;
let mockStorage;
let sessionStore;
let cryptoStore;
let megolmDecryption;
beforeEach(async function() {
await Olm.init();
testUtils.beforeEach(this); // eslint-disable-line no-invalid-this
mockCrypto = testUtils.mock(Crypto, 'Crypto');
mockCrypto.backupKey = new Olm.PkEncryption();
mockCrypto.backupKey.set_recipient_key(
"hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
);
mockCrypto.backupInfo = {
algorithm: "m.megolm_backup.v1",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
};
mockStorage = new MockStorageApi();
sessionStore = new WebStorageSessionStore(mockStorage);
cryptoStore = new MemoryCryptoStore(mockStorage);
olmDevice = new OlmDevice(sessionStore, cryptoStore);
// we stub out the olm encryption bits
mockOlmLib = {};
mockOlmLib.ensureOlmSessionsForDevices = expect.createSpy();
mockOlmLib.encryptMessageForDevice =
expect.createSpy().andReturn(Promise.resolve());
});
describe("backup", function() {
let mockBaseApis;
let realSetTimeout;
beforeEach(function() {
mockBaseApis = {};
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: mockBaseApis,
roomId: ROOM_ID,
});
megolmDecryption.olmlib = mockOlmLib;
// clobber the setTimeout function to run 100x faster.
// ideally we would use lolex, but we have no oportunity
// to tick the clock between the first try and the retry.
realSetTimeout = global.setTimeout;
global.setTimeout = function(f, n) {
return realSetTimeout(f, n/100);
};
});
afterEach(function() {
global.setTimeout = realSetTimeout;
});
it('automatically calls the key back up', function() {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
// construct a fake decrypted key event via the use of a mocked
// 'crypto' implementation.
const event = new MatrixEvent({
type: 'm.room.encrypted',
});
const decryptedData = {
clearEvent: {
type: 'm.room_key',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
room_id: ROOM_ID,
session_id: groupSession.session_id(),
session_key: groupSession.session_key(),
},
},
senderCurve25519Key: "SENDER_CURVE25519",
claimedEd25519Key: "SENDER_ED25519",
};
mockCrypto.decryptEvent = function() {
return Promise.resolve(decryptedData);
};
mockCrypto.cancelRoomKeyRequest = function() {};
mockCrypto.backupGroupSession = expect.createSpy();
return event.attemptDecryption(mockCrypto).then(() => {
return megolmDecryption.onRoomKeyEvent(event);
}).then(() => {
expect(mockCrypto.backupGroupSession).toHaveBeenCalled();
});
});
it('sends backups to the server', function() {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const client = makeTestClient(sessionStore, cryptoStore);
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
});
megolmDecryption.olmlib = mockOlmLib;
return client.initCrypto()
.then(() => {
return cryptoStore.doTxn(
"readwrite",
[cryptoStore.STORE_SESSION],
(txn) => {
cryptoStore.addEndToEndInboundGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
{
forwardingCurve25519KeyChain: undefined,
keysClaimed: {
ed25519: "SENDER_ED25519",
},
room_id: ROOM_ID,
session: ibGroupSession.pickle(olmDevice._pickleKey),
},
txn);
});
})
.then(() => {
client.enableKeyBackup({
algorithm: "m.megolm_backup.v1",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
});
let numCalls = 0;
return new Promise((resolve, reject) => {
client._http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
++numCalls;
expect(numCalls).toBeLessThanOrEqualTo(1);
if (numCalls >= 2) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams.version).toBe(1);
expect(data.rooms[ROOM_ID].sessions).toExist();
expect(data.rooms[ROOM_ID].sessions).toIncludeKey(
groupSession.session_id(),
);
resolve();
return Promise.resolve({});
};
client._crypto.backupGroupSession(
"roomId",
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
[],
groupSession.session_id(),
groupSession.session_key(),
);
}).then(() => {
expect(numCalls).toBe(1);
});
});
});
it('retries when a backup fails', function() {
const groupSession = new Olm.OutboundGroupSession();
groupSession.create();
const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key());
const scheduler = [
"getQueueForEvent", "queueEvent", "removeEventFromQueue",
"setProcessFunction",
].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {});
const store = [
"getRoom", "getRooms", "getUser", "getSyncToken", "scrollback",
"save", "wantsSave", "setSyncToken", "storeEvents", "storeRoom",
"storeUser", "getFilterIdByName", "setFilterIdByName", "getFilter",
"storeFilter", "getSyncAccumulator", "startup", "deleteAllData",
].reduce((r, k) => {r[k] = expect.createSpy(); return r;}, {});
store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null));
store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null));
store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null));
const client = new MatrixClient({
baseUrl: "https://my.home.server",
idBaseUrl: "https://identity.server",
accessToken: "my.access.token",
request: function() {}, // NOP
store: store,
scheduler: scheduler,
userId: "@alice:bar",
deviceId: "device",
sessionStore: sessionStore,
cryptoStore: cryptoStore,
});
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
});
megolmDecryption.olmlib = mockOlmLib;
return client.initCrypto()
.then(() => {
return cryptoStore.doTxn(
"readwrite",
[cryptoStore.STORE_SESSION],
(txn) => {
cryptoStore.addEndToEndInboundGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
groupSession.session_id(),
{
forwardingCurve25519KeyChain: undefined,
keysClaimed: {
ed25519: "SENDER_ED25519",
},
room_id: ROOM_ID,
session: ibGroupSession.pickle(olmDevice._pickleKey),
},
txn);
});
})
.then(() => {
client.enableKeyBackup({
algorithm: "foobar",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
});
let numCalls = 0;
return new Promise((resolve, reject) => {
client._http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
++numCalls;
expect(numCalls).toBeLessThanOrEqualTo(2);
if (numCalls >= 3) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
}
expect(method).toBe("PUT");
expect(path).toBe("/room_keys/keys");
expect(queryParams.version).toBe(1);
expect(data.rooms[ROOM_ID].sessions).toExist();
expect(data.rooms[ROOM_ID].sessions).toIncludeKey(
groupSession.session_id(),
);
if (numCalls > 1) {
resolve();
return Promise.resolve({});
} else {
return Promise.reject(
new Error("this is an expected failure"),
);
}
};
client._crypto.backupGroupSession(
"roomId",
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
[],
groupSession.session_id(),
groupSession.session_key(),
);
}).then(() => {
expect(numCalls).toBe(2);
});
});
});
});
describe("restore", function() {
let client;
beforeEach(function() {
client = makeTestClient(sessionStore, cryptoStore);
megolmDecryption = new MegolmDecryption({
userId: '@user:id',
crypto: mockCrypto,
olmDevice: olmDevice,
baseApis: client,
roomId: ROOM_ID,
});
megolmDecryption.olmlib = mockOlmLib;
return client.initCrypto();
});
afterEach(function() {
client.stopClient();
});
it('can restore from backup', function() {
client._http.authedRequest = function() {
return Promise.resolve(KEY_BACKUP_DATA);
};
return client.restoreKeyBackupWithRecoveryKey(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
ROOM_ID,
SESSION_ID,
).then(() => {
return megolmDecryption.decryptEvent(ENCRYPTED_EVENT);
}).then((res) => {
expect(res.clearEvent.content).toEqual('testytest');
});
});
it('can restore backup by room', function() {
client._http.authedRequest = function() {
return Promise.resolve({
rooms: {
[ROOM_ID]: {
sessions: {
[SESSION_ID]: KEY_BACKUP_DATA,
},
},
},
});
};
return client.restoreKeyBackupWithRecoveryKey(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
).then(() => {
return megolmDecryption.decryptEvent(ENCRYPTED_EVENT);
}).then((res) => {
expect(res.clearEvent.content).toEqual('testytest');
});
});
});
});

View File

@@ -139,6 +139,9 @@ describe("MatrixClient", function() {
store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null)); store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null));
store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null)); store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null));
store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null)); store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null));
store.getClientOptions = expect.createSpy().andReturn(Promise.resolve(null));
store.storeClientOptions = expect.createSpy().andReturn(Promise.resolve(null));
store.isNewlyCreated = expect.createSpy().andReturn(Promise.resolve(true));
client = new MatrixClient({ client = new MatrixClient({
baseUrl: "https://my.home.server", baseUrl: "https://my.home.server",
idBaseUrl: identityServerUrl, idBaseUrl: identityServerUrl,
@@ -182,7 +185,7 @@ describe("MatrixClient", function() {
}); });
}); });
it("should not POST /filter if a matching filter already exists", function(done) { it("should not POST /filter if a matching filter already exists", async function() {
httpLookups = []; httpLookups = [];
httpLookups.push(PUSH_RULES_RESPONSE); httpLookups.push(PUSH_RULES_RESPONSE);
httpLookups.push(SYNC_RESPONSE); httpLookups.push(SYNC_RESPONSE);
@@ -191,31 +194,38 @@ describe("MatrixClient", function() {
const filter = new sdk.Filter(0, filterId); const filter = new sdk.Filter(0, filterId);
filter.setDefinition({"room": {"timeline": {"limit": 8}}}); filter.setDefinition({"room": {"timeline": {"limit": 8}}});
store.getFilter.andReturn(filter); store.getFilter.andReturn(filter);
client.startClient(); const syncPromise = new Promise((resolve, reject) => {
client.on("sync", function syncListener(state) { client.on("sync", function syncListener(state) {
if (state === "SYNCING") { if (state === "SYNCING") {
expect(httpLookups.length).toEqual(0); expect(httpLookups.length).toEqual(0);
client.removeListener("sync", syncListener); client.removeListener("sync", syncListener);
done(); resolve();
} else if (state === "ERROR") {
reject(new Error("sync error"));
} }
}); });
}); });
await client.startClient();
await syncPromise;
});
describe("getSyncState", function() { describe("getSyncState", function() {
it("should return null if the client isn't started", function() { it("should return null if the client isn't started", function() {
expect(client.getSyncState()).toBe(null); expect(client.getSyncState()).toBe(null);
}); });
it("should return the same sync state as emitted sync events", function(done) { it("should return the same sync state as emitted sync events", async function() {
const syncingPromise = new Promise((resolve) => {
client.on("sync", function syncListener(state) { client.on("sync", function syncListener(state) {
expect(state).toEqual(client.getSyncState()); expect(state).toEqual(client.getSyncState());
if (state === "SYNCING") { if (state === "SYNCING") {
client.removeListener("sync", syncListener); client.removeListener("sync", syncListener);
done(); resolve();
} }
}); });
client.startClient(); });
await client.startClient();
await syncingPromise;
}); });
}); });
@@ -258,8 +268,8 @@ describe("MatrixClient", function() {
}); });
describe("retryImmediately", function() { describe("retryImmediately", function() {
it("should return false if there is no request waiting", function() { it("should return false if there is no request waiting", async function() {
client.startClient(); await client.startClient();
expect(client.retryImmediately()).toBe(false); expect(client.retryImmediately()).toBe(false);
}); });
@@ -380,7 +390,7 @@ describe("MatrixClient", function() {
client.startClient(); client.startClient();
}); });
it("should transition ERROR -> PREPARED after /sync if prev failed", it("should transition ERROR -> CATCHUP after /sync if prev failed",
function(done) { function(done) {
const expectedStates = []; const expectedStates = [];
acceptKeepalives = false; acceptKeepalives = false;
@@ -403,7 +413,7 @@ describe("MatrixClient", function() {
expectedStates.push(["RECONNECTING", null]); expectedStates.push(["RECONNECTING", null]);
expectedStates.push(["ERROR", "RECONNECTING"]); expectedStates.push(["ERROR", "RECONNECTING"]);
expectedStates.push(["PREPARED", "ERROR"]); expectedStates.push(["CATCHUP", "ERROR"]);
client.on("sync", syncChecker(expectedStates, done)); client.on("sync", syncChecker(expectedStates, done));
client.startClient(); client.startClient();
}); });

View File

@@ -192,6 +192,15 @@ describe("RoomMember", function() {
}); });
}); });
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() { describe("setMembershipEvent", function() {
const joinEvent = utils.mkMembership({ const joinEvent = utils.mkMembership({
event: true, event: true,

View File

@@ -11,6 +11,9 @@ describe("RoomState", function() {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
const userA = "@alice:bar"; const userA = "@alice:bar";
const userB = "@bob:bar"; const userB = "@bob:bar";
const userC = "@cleo:bar";
const userLazy = "@lazy:bar";
let state; let state;
beforeEach(function() { beforeEach(function() {
@@ -78,8 +81,8 @@ describe("RoomState", function() {
}); });
describe("getSentinelMember", function() { describe("getSentinelMember", function() {
it("should return null if there is no member", function() { it("should return a member with the user id as name", function() {
expect(state.getSentinelMember("@no-one:here")).toEqual(null); 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", it("should return a member which doesn't change when the state is updated",
@@ -162,6 +165,7 @@ describe("RoomState", function() {
]; ];
let emitCount = 0; let emitCount = 0;
state.on("RoomState.newMember", function(ev, st, mem) { state.on("RoomState.newMember", function(ev, st, mem) {
expect(state.getMember(mem.userId)).toEqual(mem);
expect(mem.userId).toEqual(memberEvents[emitCount].getSender()); expect(mem.userId).toEqual(memberEvents[emitCount].getSender());
expect(mem.membership).toBeFalsy(); // not defined yet expect(mem.membership).toBeFalsy(); // not defined yet
emitCount += 1; emitCount += 1;
@@ -222,7 +226,6 @@ describe("RoomState", function() {
it("should call setPowerLevelEvent on a new RoomMember if power levels exist", it("should call setPowerLevelEvent on a new RoomMember if power levels exist",
function() { function() {
const userC = "@cleo:bar";
const memberEvent = utils.mkMembership({ const memberEvent = utils.mkMembership({
mship: "join", user: userC, room: roomId, event: true, mship: "join", user: userC, room: roomId, event: true,
}); });
@@ -262,6 +265,114 @@ describe("RoomState", function() {
}); });
}); });
describe("setOutOfBandMembers", function() {
it("should add a new member", function() {
const oobMemberEvent = utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
});
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]);
const member = state.getMember(userLazy);
expect(member.userId).toEqual(userLazy);
expect(member.isOutOfBand()).toEqual(true);
});
it("should have no effect when not in correct status", function() {
state.setOutOfBandMembers([utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
})]);
expect(state.getMember(userLazy)).toBeFalsy();
});
it("should emit newMember when adding a member", function() {
const userLazy = "@oob:hs";
const oobMemberEvent = utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
});
let eventReceived = false;
state.once('RoomState.newMember', (_, __, member) => {
expect(member.userId).toEqual(userLazy);
eventReceived = true;
});
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]);
expect(eventReceived).toEqual(true);
});
it("should never overwrite existing members", function() {
const oobMemberEvent = utils.mkMembership({
user: userA, mship: "join", room: roomId, event: true,
});
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]);
const memberA = state.getMember(userA);
expect(memberA.events.member.getId()).toNotEqual(oobMemberEvent.getId());
expect(memberA.isOutOfBand()).toEqual(false);
});
it("should emit members when updating a member", function() {
const doesntExistYetUserId = "@doesntexistyet:hs";
const oobMemberEvent = utils.mkMembership({
user: doesntExistYetUserId, mship: "join", room: roomId, event: true,
});
let eventReceived = false;
state.once('RoomState.members', (_, __, member) => {
expect(member.userId).toEqual(doesntExistYetUserId);
eventReceived = true;
});
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]);
expect(eventReceived).toEqual(true);
});
});
describe("clone", function() {
it("should contain same information as original", function() {
// include OOB members in copy
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
})]);
const copy = state.clone();
// check individual members
[userA, userB, userLazy].forEach((userId) => {
const member = state.getMember(userId);
const memberCopy = copy.getMember(userId);
expect(member.name).toEqual(memberCopy.name);
expect(member.isOutOfBand()).toEqual(memberCopy.isOutOfBand());
});
// check member keys
expect(Object.keys(state.members)).toEqual(Object.keys(copy.members));
// check join count
expect(state.getJoinedMemberCount()).toEqual(copy.getJoinedMemberCount());
});
it("should mark old copy as not waiting for out of band anymore", function() {
state.markOutOfBandMembersStarted();
const copy = state.clone();
copy.setOutOfBandMembers([utils.mkMembership({
user: userA, mship: "join", room: roomId, event: true,
})]);
// should have no effect as it should be marked in status finished just like copy
state.setOutOfBandMembers([utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
})]);
expect(state.getMember(userLazy)).toBeFalsy();
});
it("should return copy independent of original", function() {
const copy = state.clone();
copy.setStateEvents([utils.mkMembership({
user: userLazy, mship: "join", room: roomId, event: true,
})]);
expect(state.getMember(userLazy)).toBeFalsy();
expect(state.getJoinedMemberCount()).toEqual(2);
expect(copy.getJoinedMemberCount()).toEqual(3);
});
});
describe("setTypingEvent", function() { describe("setTypingEvent", function() {
it("should call setTypingEvent on each RoomMember", function() { it("should call setTypingEvent on each RoomMember", function() {
const typingEvent = utils.mkEvent({ const typingEvent = utils.mkEvent({
@@ -284,13 +395,6 @@ describe("RoomState", function() {
}); });
describe("maySendStateEvent", function() { describe("maySendStateEvent", function() {
it("should say non-joined members may not send state",
function() {
expect(state.maySendStateEvent(
'm.room.name', "@nobody:nowhere",
)).toEqual(false);
});
it("should say any member may send state with no power level event", it("should say any member may send state with no power level event",
function() { function() {
expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true);
@@ -366,15 +470,117 @@ describe("RoomState", function() {
}); });
}); });
describe("maySendEvent", function() { describe("getJoinedMemberCount", function() {
it("should say non-joined members may not send events", beforeEach(() => {
function() { state = new RoomState(roomId);
expect(state.maySendEvent(
'm.room.message', "@nobody:nowhere",
)).toEqual(false);
expect(state.maySendMessage("@nobody:nowhere")).toEqual(false);
}); });
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", it("should say any member may send events with no power level event",
function() { function() {
expect(state.maySendEvent('m.room.message', userA)).toEqual(true); expect(state.maySendEvent('m.room.message', userA)).toEqual(true);

View File

@@ -67,13 +67,14 @@ describe("Room", function() {
describe("getMember", function() { describe("getMember", function() {
beforeEach(function() { beforeEach(function() {
// clobber members property with test data room.currentState.getMember.andCall(function(userId) {
room.currentState.members = { return {
"@alice:bar": { "@alice:bar": {
userId: userA, userId: userA,
roomId: roomId, roomId: roomId,
}, },
}; }[userId];
});
}); });
it("should return null if the member isn't in current state", function() { it("should return null if the member isn't in current state", function() {
@@ -386,7 +387,7 @@ describe("Room", function() {
let events = null; let events = null;
beforeEach(function() { beforeEach(function() {
room = new Room(roomId, {timelineSupport: timelineSupport}); room = new Room(roomId, null, null, {timelineSupport: timelineSupport});
// set events each time to avoid resusing Event objects (which // set events each time to avoid resusing Event objects (which
// doesn't work because they get frozen) // doesn't work because they get frozen)
events = [ events = [
@@ -468,7 +469,7 @@ describe("Room", function() {
describe("compareEventOrdering", function() { describe("compareEventOrdering", function() {
beforeEach(function() { beforeEach(function() {
room = new Room(roomId, {timelineSupport: true}); room = new Room(roomId, null, null, {timelineSupport: true});
}); });
const events = [ const events = [
@@ -570,72 +571,75 @@ describe("Room", function() {
describe("hasMembershipState", function() { describe("hasMembershipState", function() {
it("should return true for a matching userId and membership", it("should return true for a matching userId and membership",
function() { function() {
room.currentState.members = { room.currentState.getMember.andCall(function(userId) {
return {
"@alice:bar": { userId: "@alice:bar", membership: "join" }, "@alice:bar": { userId: "@alice:bar", membership: "join" },
"@bob:bar": { userId: "@bob:bar", membership: "invite" }, "@bob:bar": { userId: "@bob:bar", membership: "invite" },
}; }[userId];
});
expect(room.hasMembershipState("@bob:bar", "invite")).toBe(true); expect(room.hasMembershipState("@bob:bar", "invite")).toBe(true);
}); });
it("should return false if match membership but no match userId", it("should return false if match membership but no match userId",
function() { function() {
room.currentState.members = { room.currentState.getMember.andCall(function(userId) {
return {
"@alice:bar": { userId: "@alice:bar", membership: "join" }, "@alice:bar": { userId: "@alice:bar", membership: "join" },
}; }[userId];
});
expect(room.hasMembershipState("@bob:bar", "join")).toBe(false); expect(room.hasMembershipState("@bob:bar", "join")).toBe(false);
}); });
it("should return false if match userId but no match membership", it("should return false if match userId but no match membership",
function() { function() {
room.currentState.members = { room.currentState.getMember.andCall(function(userId) {
return {
"@alice:bar": { userId: "@alice:bar", membership: "join" }, "@alice:bar": { userId: "@alice:bar", membership: "join" },
}; }[userId];
});
expect(room.hasMembershipState("@alice:bar", "ban")).toBe(false); expect(room.hasMembershipState("@alice:bar", "ban")).toBe(false);
}); });
it("should return false if no match membership or userId", it("should return false if no match membership or userId",
function() { function() {
room.currentState.members = { room.currentState.getMember.andCall(function(userId) {
return {
"@alice:bar": { userId: "@alice:bar", membership: "join" }, "@alice:bar": { userId: "@alice:bar", membership: "join" },
}; }[userId];
});
expect(room.hasMembershipState("@bob:bar", "invite")).toBe(false); expect(room.hasMembershipState("@bob:bar", "invite")).toBe(false);
}); });
it("should return false if no members exist", it("should return false if no members exist",
function() { function() {
room.currentState.members = {};
expect(room.hasMembershipState("@foo:bar", "join")).toBe(false); expect(room.hasMembershipState("@foo:bar", "join")).toBe(false);
}); });
}); });
describe("recalculate", function() { describe("recalculate", function() {
let stateLookup = {
// event.type + "$" event.state_key : MatrixEvent
};
const setJoinRule = function(rule) { const setJoinRule = function(rule) {
stateLookup["m.room.join_rules$"] = utils.mkEvent({ room.addLiveEvents([utils.mkEvent({
type: "m.room.join_rules", room: roomId, user: userA, content: { type: "m.room.join_rules", room: roomId, user: userA, content: {
join_rule: rule, join_rule: rule,
}, event: true, }, event: true,
}); })]);
}; };
const setAliases = function(aliases, stateKey) { const setAliases = function(aliases, stateKey) {
if (!stateKey) { if (!stateKey) {
stateKey = "flibble"; stateKey = "flibble";
} }
stateLookup["m.room.aliases$" + stateKey] = utils.mkEvent({ room.addLiveEvents([utils.mkEvent({
type: "m.room.aliases", room: roomId, skey: stateKey, content: { type: "m.room.aliases", room: roomId, skey: stateKey, content: {
aliases: aliases, aliases: aliases,
}, event: true, }, event: true,
}); })]);
}; };
const setRoomName = function(name) { const setRoomName = function(name) {
stateLookup["m.room.name$"] = utils.mkEvent({ room.addLiveEvents([utils.mkEvent({
type: "m.room.name", room: roomId, user: userA, content: { type: "m.room.name", room: roomId, user: userA, content: {
name: name, name: name,
}, event: true, }, event: true,
}); })]);
}; };
const addMember = function(userId, state, opts) { const addMember = function(userId, state, opts) {
if (!state) { if (!state) {
@@ -647,56 +651,14 @@ describe("Room", function() {
opts.user = opts.user || userId; opts.user = opts.user || userId;
opts.skey = userId; opts.skey = userId;
opts.event = true; opts.event = true;
stateLookup["m.room.member$" + userId] = utils.mkMembership(opts); const event = utils.mkMembership(opts);
room.addLiveEvents([event]);
return event;
}; };
beforeEach(function() { beforeEach(function() {
stateLookup = {}; // no mocking
room.currentState.getStateEvents.andCall(function(type, key) { room = new Room(roomId, null, userA);
if (key === undefined) {
const prefix = type + "$";
const list = [];
for (const stateBlob in stateLookup) {
if (!stateLookup.hasOwnProperty(stateBlob)) {
continue;
}
if (stateBlob.indexOf(prefix) === 0) {
list.push(stateLookup[stateBlob]);
}
}
return list;
} else {
return stateLookup[type + "$" + key];
}
});
room.currentState.getMembers.andCall(function() {
const memberEvents = room.currentState.getStateEvents("m.room.member");
const members = [];
for (let i = 0; i < memberEvents.length; i++) {
members.push({
name: memberEvents[i].event.content &&
memberEvents[i].event.content.displayname ?
memberEvents[i].event.content.displayname :
memberEvents[i].getStateKey(),
userId: memberEvents[i].getStateKey(),
events: { member: memberEvents[i] },
});
}
return members;
});
room.currentState.getMember.andCall(function(userId) {
const memberEvent = room.currentState.getStateEvents(
"m.room.member", userId,
);
return {
name: memberEvent.event.content &&
memberEvent.event.content.displayname ?
memberEvent.event.content.displayname :
memberEvent.getStateKey(),
userId: memberEvent.getStateKey(),
events: { member: memberEvent },
};
});
}); });
describe("Room.recalculate => Stripped State Events", function() { describe("Room.recalculate => Stripped State Events", function() {
@@ -704,8 +666,8 @@ describe("Room", function() {
"room is an invite room", function() { "room is an invite room", function() {
const roomName = "flibble"; const roomName = "flibble";
addMember(userA, "invite"); const event = addMember(userA, "invite");
stateLookup["m.room.member$" + userA].event.invite_room_state = [ event.event.invite_room_state = [
{ {
type: "m.room.name", type: "m.room.name",
state_key: "", state_key: "",
@@ -715,30 +677,108 @@ describe("Room", function() {
}, },
]; ];
room.recalculate(userA); room.recalculate();
expect(room.currentState.setStateEvents).toHaveBeenCalled(); expect(room.name).toEqual(roomName);
// first call, first arg (which is an array), first element in array
const fakeEvent = room.currentState.setStateEvents.calls[0].
arguments[0][0];
expect(fakeEvent.getContent()).toEqual({
name: roomName,
});
}); });
it("should not clobber state events if it isn't an invite room", function() { it("should not clobber state events if it isn't an invite room", function() {
addMember(userA, "join"); const event = addMember(userA, "join");
stateLookup["m.room.member$" + userA].event.invite_room_state = [ const roomName = "flibble";
setRoomName(roomName);
const roomNameToIgnore = "ignoreme";
event.event.invite_room_state = [
{ {
type: "m.room.name", type: "m.room.name",
state_key: "", state_key: "",
content: { content: {
name: "flibble", name: roomNameToIgnore,
}, },
}, },
]; ];
room.recalculate(userA); room.recalculate();
expect(room.currentState.setStateEvents).toNotHaveBeenCalled(); 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");
}); });
}); });
@@ -751,7 +791,7 @@ describe("Room", function() {
addMember(userB); addMember(userB);
addMember(userC); addMember(userC);
addMember(userD); addMember(userD);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
// we expect at least 1 member to be mentioned // we expect at least 1 member to be mentioned
const others = [userB, userC, userD]; const others = [userB, userC, userD];
@@ -772,7 +812,7 @@ describe("Room", function() {
addMember(userA); addMember(userA);
addMember(userB); addMember(userB);
addMember(userC); addMember(userC);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name.indexOf(userB)).toNotEqual(-1, name); expect(name.indexOf(userB)).toNotEqual(-1, name);
expect(name.indexOf(userC)).toNotEqual(-1, name); expect(name.indexOf(userC)).toNotEqual(-1, name);
@@ -785,7 +825,7 @@ describe("Room", function() {
addMember(userA); addMember(userA);
addMember(userB); addMember(userB);
addMember(userC); addMember(userC);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name.indexOf(userB)).toNotEqual(-1, name); expect(name.indexOf(userB)).toNotEqual(-1, name);
expect(name.indexOf(userC)).toNotEqual(-1, name); expect(name.indexOf(userC)).toNotEqual(-1, name);
@@ -797,7 +837,7 @@ describe("Room", function() {
setJoinRule("public"); setJoinRule("public");
addMember(userA); addMember(userA);
addMember(userB); addMember(userB);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name.indexOf(userB)).toNotEqual(-1, name); expect(name.indexOf(userB)).toNotEqual(-1, name);
}); });
@@ -808,7 +848,7 @@ describe("Room", function() {
setJoinRule("invite"); setJoinRule("invite");
addMember(userA); addMember(userA);
addMember(userB); addMember(userB);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name.indexOf(userB)).toNotEqual(-1, name); expect(name.indexOf(userB)).toNotEqual(-1, name);
}); });
@@ -818,7 +858,7 @@ describe("Room", function() {
setJoinRule("invite"); setJoinRule("invite");
addMember(userA, "invite", {user: userB}); addMember(userA, "invite", {user: userB});
addMember(userB); addMember(userB);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name.indexOf(userB)).toNotEqual(-1, name); expect(name.indexOf(userB)).toNotEqual(-1, name);
}); });
@@ -828,7 +868,7 @@ describe("Room", function() {
const alias = "#room_alias:here"; const alias = "#room_alias:here";
setJoinRule("invite"); setJoinRule("invite");
setAliases([alias, "#another:one"]); setAliases([alias, "#another:one"]);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual(alias); expect(name).toEqual(alias);
}); });
@@ -838,7 +878,7 @@ describe("Room", function() {
const alias = "#room_alias:here"; const alias = "#room_alias:here";
setJoinRule("public"); setJoinRule("public");
setAliases([alias, "#another:one"]); setAliases([alias, "#another:one"]);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual(alias); expect(name).toEqual(alias);
}); });
@@ -848,7 +888,7 @@ describe("Room", function() {
const roomName = "A mighty name indeed"; const roomName = "A mighty name indeed";
setJoinRule("invite"); setJoinRule("invite");
setRoomName(roomName); setRoomName(roomName);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual(roomName); expect(name).toEqual(roomName);
}); });
@@ -858,25 +898,23 @@ describe("Room", function() {
const roomName = "A mighty name indeed"; const roomName = "A mighty name indeed";
setJoinRule("public"); setJoinRule("public");
setRoomName(roomName); setRoomName(roomName);
room.recalculate(userA); room.recalculate();
const name = room.name; expect(room.name).toEqual(roomName);
expect(name).toEqual(roomName);
}); });
it("should return 'Empty room' for private (invite join_rules) rooms if" + 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() { " a room name and alias don't exist and it is a self-chat.", function() {
setJoinRule("invite"); setJoinRule("invite");
addMember(userA); addMember(userA);
room.recalculate(userA); room.recalculate();
const name = room.name; expect(room.name).toEqual("Empty room");
expect(name).toEqual("Empty room");
}); });
it("should return 'Empty room' for public (public join_rules) rooms if a" + 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() { " room name and alias don't exist and it is a self-chat.", function() {
setJoinRule("public"); setJoinRule("public");
addMember(userA); addMember(userA);
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual("Empty room"); expect(name).toEqual("Empty room");
}); });
@@ -884,7 +922,7 @@ describe("Room", function() {
it("should return 'Empty room' if there is no name, " + it("should return 'Empty room' if there is no name, " +
"alias or members in the room.", "alias or members in the room.",
function() { function() {
room.recalculate(userA); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual("Empty room"); expect(name).toEqual("Empty room");
}); });
@@ -893,9 +931,9 @@ describe("Room", function() {
"available", "available",
function() { function() {
setJoinRule("invite"); setJoinRule("invite");
addMember(userA, 'join', {name: "Alice"}); addMember(userB, 'join', {name: "Alice"});
addMember(userB, "invite", {user: userA}); addMember(userA, "invite", {user: userA});
room.recalculate(userB); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual("Alice"); expect(name).toEqual("Alice");
}); });
@@ -903,11 +941,11 @@ describe("Room", function() {
it("should return inviter mxid if display name not available", it("should return inviter mxid if display name not available",
function() { function() {
setJoinRule("invite"); setJoinRule("invite");
addMember(userA); addMember(userB);
addMember(userB, "invite", {user: userA}); addMember(userA, "invite", {user: userA});
room.recalculate(userB); room.recalculate();
const name = room.name; const name = room.name;
expect(name).toEqual(userA); expect(name).toEqual(userB);
}); });
}); });
}); });
@@ -1154,7 +1192,7 @@ describe("Room", function() {
describe("addPendingEvent", function() { describe("addPendingEvent", function() {
it("should add pending events to the pendingEventList if " + it("should add pending events to the pendingEventList if " +
"pendingEventOrdering == 'detached'", function() { "pendingEventOrdering == 'detached'", function() {
const room = new Room(roomId, { const room = new Room(roomId, null, userA, {
pendingEventOrdering: "detached", pendingEventOrdering: "detached",
}); });
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
@@ -1180,7 +1218,7 @@ describe("Room", function() {
it("should add pending events to the timeline if " + it("should add pending events to the timeline if " +
"pendingEventOrdering == 'chronological'", function() { "pendingEventOrdering == 'chronological'", function() {
room = new Room(roomId, { room = new Room(roomId, null, userA, {
pendingEventOrdering: "chronological", pendingEventOrdering: "chronological",
}); });
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
@@ -1204,7 +1242,7 @@ describe("Room", function() {
describe("updatePendingEvent", function() { describe("updatePendingEvent", function() {
it("should remove cancelled events from the pending list", function() { it("should remove cancelled events from the pending list", function() {
const room = new Room(roomId, { const room = new Room(roomId, null, userA, {
pendingEventOrdering: "detached", pendingEventOrdering: "detached",
}); });
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
@@ -1240,7 +1278,7 @@ describe("Room", function() {
it("should remove cancelled events from the timeline", function() { it("should remove cancelled events from the timeline", function() {
const room = new Room(roomId); const room = new Room(roomId, null, userA);
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
room: roomId, user: userA, event: true, room: roomId, user: userA, event: true,
}); });
@@ -1272,4 +1310,153 @@ describe("Room", function() {
expect(callCount).toEqual(1); 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;};
},
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);
});
});
}); });

View File

@@ -52,6 +52,11 @@ describe("SyncAccumulator", function() {
member("bob", "join"), member("bob", "join"),
], ],
}, },
summary: {
"m.heroes": undefined,
"m.joined_member_count": undefined,
"m.invited_member_count": undefined,
},
timeline: { timeline: {
events: [msg("alice", "hi")], events: [msg("alice", "hi")],
prev_batch: "something", prev_batch: "something",
@@ -318,6 +323,58 @@ describe("SyncAccumulator", function() {
}, },
}); });
}); });
describe("summary field", function() {
function createSyncResponseWithSummary(summary) {
return {
next_batch: "abc",
rooms: {
invite: {},
leave: {},
join: {
"!foo:bar": {
account_data: { events: [] },
ephemeral: { events: [] },
unread_notifications: {},
state: {
events: [],
},
summary: summary,
timeline: {
events: [],
prev_batch: "something",
},
},
},
},
};
}
it("should copy summary properties", function() {
sa.accumulate(createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
"m.invited_member_count": 2,
}));
const summary = sa.getJSON().roomsData.join["!foo:bar"].summary;
expect(summary["m.invited_member_count"]).toEqual(2);
expect(summary["m.heroes"]).toEqual(["@alice:bar"]);
});
it("should accumulate summary properties", function() {
sa.accumulate(createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
"m.invited_member_count": 2,
}));
sa.accumulate(createSyncResponseWithSummary({
"m.heroes": ["@bob:bar"],
"m.joined_member_count": 5,
}));
const summary = sa.getJSON().roomsData.join["!foo:bar"].summary;
expect(summary["m.invited_member_count"]).toEqual(2);
expect(summary["m.joined_member_count"]).toEqual(5);
expect(summary["m.heroes"]).toEqual(["@bob:bar"]);
});
});
}); });
function syncSkeleton(joinObj) { function syncSkeleton(joinObj) {

390
src/autodiscovery.js Normal file
View File

@@ -0,0 +1,390 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/** @module auto-discovery */
import Promise from 'bluebird';
const logger = require("./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.
/**
* 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"; }
/**
* 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<DiscoveredClientConfig>} 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: "Invalid homeserver discovery response",
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"
|| !wellknown.raw["m.homeserver"]
|| !wellknown.raw["m.homeserver"]["base_url"]) {
logger.error("No m.homeserver key in well-known response");
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;
}
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.raw["m.homeserver"]["base_url"],
);
if (!hsUrl) {
logger.error("Invalid base_url for m.homeserver");
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");
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.raw["m.identity_server"]) {
// We prepare a failing identity server response to save lines later
// in this branch. Note that we also fail the homeserver check in the
// object because according to the spec we're supposed to FAIL_ERROR
// if *anything* goes wrong with the IS validation, including invalid
// format. This means we're supposed to stop discovery completely.
const failingClientConfig = {
"m.homeserver": {
state: AutoDiscovery.FAIL_ERROR,
error: "Invalid identity server discovery response",
// We'll provide the base_url that was previously valid for
// debugging purposes.
base_url: clientConfig["m.homeserver"].base_url,
},
"m.identity_server": {
state: AutoDiscovery.FAIL_ERROR,
error: "Invalid identity server discovery response",
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.raw["m.identity_server"]["base_url"],
);
if (!isUrl) {
logger.error("Invalid base_url for m.identity_server");
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");
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.raw)
.filter((k) => k !== "m.homeserver" && k !== "m.identity_server")
.map((k) => clientConfig[k] = wellknown.raw[k]);
// Step 8: Give the config to the caller (finally)
return Promise.resolve(clientConfig);
}
/**
* 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<object>} 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 },
(err, response, body) => {
if (err || response.statusCode < 200 || response.statusCode >= 300) {
let action = "FAIL_PROMPT";
let reason = (err ? err.message : null) || "General failure";
if (response.statusCode === 404) {
action = "IGNORE";
reason = "No .well-known JSON file found";
}
resolve({raw: {}, action: action, reason: reason, error: err});
return;
}
try {
resolve({raw: JSON.parse(body), action: "SUCCESS"});
} catch (e) {
let reason = "General failure";
if (e.name === "SyntaxError") reason = "Invalid JSON";
resolve({
raw: {},
action: "FAIL_PROMPT",
reason: reason, error: e,
});
}
},
);
});
}
}

View File

@@ -262,7 +262,19 @@ MatrixBaseApis.prototype.login = function(loginType, data, callback) {
utils.extend(login_data, data); utils.extend(login_data, data);
return this._http.authedRequest( return this._http.authedRequest(
callback, "POST", "/login", undefined, login_data, (error, response) => {
if (loginType === "m.login.password" && 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,
); );
}; };
@@ -298,9 +310,23 @@ MatrixBaseApis.prototype.loginWithSAML2 = function(relayState, callback) {
* @return {string} The HS URL to hit to begin the CAS login process. * @return {string} The HS URL to hit to begin the CAS login process.
*/ */
MatrixBaseApis.prototype.getCasLoginUrl = function(redirectUrl) { MatrixBaseApis.prototype.getCasLoginUrl = function(redirectUrl) {
return this._http.getUrl("/login/cas/redirect", { return this.getSsoLoginUrl(redirectUrl, "cas");
};
/**
* @param {string} redirectUrl The URL to redirect to after the HS
* authenticates with the SSO.
* @param {string} loginType The type of SSO login we are doing (sso or cas).
* Defaults to 'sso'.
* @return {string} The HS URL to hit to begin the SSO login process.
*/
MatrixBaseApis.prototype.getSsoLoginUrl = function(redirectUrl, loginType) {
if (loginType === undefined) {
loginType = "sso";
}
return this._http.getUrl("/login/"+loginType+"/redirect", {
"redirectUrl": redirectUrl, "redirectUrl": redirectUrl,
}, httpApi.PREFIX_UNSTABLE); }, httpApi.PREFIX_R0);
}; };
/** /**
@@ -417,6 +443,69 @@ MatrixBaseApis.prototype.roomState = function(roomId, callback) {
return this._http.authedRequest(callback, "GET", path); return this._http.authedRequest(callback, "GET", path);
}; };
/**
* Get an event in a room by its event id.
* @param {string} roomId
* @param {string} eventId
* @param {module:client.callback} callback Optional.
*
* @return {Promise} Resolves to an object containing the event.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixBaseApis.prototype.fetchRoomEvent = function(roomId, eventId, callback) {
const path = utils.encodeUri(
"/rooms/$roomId/event/$eventId", {
$roomId: roomId,
$eventId: eventId,
},
);
return this._http.authedRequest(callback, "GET", path);
};
/**
* @param {string} roomId
* @param {string} includeMembership the membership type to include in the response
* @param {string} excludeMembership the membership type to exclude from the response
* @param {string} atEventId the id of the event for which moment in the timeline the members should be returned for
* @param {module:client.callback} callback Optional.
* @return {module:client.Promise} Resolves: dictionary of userid to profile information
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixBaseApis.prototype.members =
function(roomId, includeMembership, excludeMembership, atEventId, callback) {
const queryParams = {};
if (includeMembership) {
queryParams.membership = includeMembership;
}
if (excludeMembership) {
queryParams.not_membership = excludeMembership;
}
if (atEventId) {
queryParams.at = atEventId;
}
const queryString = utils.encodeParams(queryParams);
const path = utils.encodeUri("/rooms/$roomId/members?" + queryString,
{$roomId: roomId});
return this._http.authedRequest(callback, "GET", path);
};
/**
* Upgrades a room to a new protocol version
* @param {string} roomId
* @param {string} newVersion The target version to upgrade to
* @return {module:client.Promise} Resolves: Object with key 'replacement_room'
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixBaseApis.prototype.upgradeRoom = function(roomId, newVersion) {
const path = utils.encodeUri("/rooms/$roomId/upgrade", {$roomId: roomId});
return this._http.authedRequest(
undefined, "POST", path, undefined, {new_version: newVersion},
);
};
/** /**
* @param {string} groupId * @param {string} groupId
* @return {module:client.Promise} Resolves: Group summary object * @return {module:client.Promise} Resolves: Group summary object
@@ -864,6 +953,28 @@ MatrixBaseApis.prototype.setRoomReadMarkersHttpRequest =
); );
}; };
/**
* @return {module:client.Promise} Resolves: A list of the user's current rooms
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixBaseApis.prototype.getJoinedRooms = function() {
const path = utils.encodeUri("/joined_rooms");
return this._http.authedRequest(undefined, "GET", path);
};
/**
* Retrieve membership info. for a room.
* @param {string} roomId ID of the room to get membership for
* @return {module:client.Promise} Resolves: A list of currently joined users
* and their profile data.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixBaseApis.prototype.getJoinedRoomMembers = function(roomId) {
const path = utils.encodeUri("/rooms/$roomId/joined_members", {
$roomId: roomId,
});
return this._http.authedRequest(undefined, "GET", path);
};
// Room Directory operations // Room Directory operations
// ========================= // =========================
@@ -1722,7 +1833,7 @@ MatrixBaseApis.prototype.getThirdpartyProtocols = function() {
* Get information on how a specific place on a third party protocol * Get information on how a specific place on a third party protocol
* may be reached. * may be reached.
* @param {string} protocol The protocol given in getThirdpartyProtocols() * @param {string} protocol The protocol given in getThirdpartyProtocols()
* @param {object} params Protocol-specific parameters, as given in th * @param {object} params Protocol-specific parameters, as given in the
* response to getThirdpartyProtocols() * response to getThirdpartyProtocols()
* @return {module:client.Promise} Resolves to the result object * @return {module:client.Promise} Resolves to the result object
*/ */
@@ -1737,6 +1848,25 @@ MatrixBaseApis.prototype.getThirdpartyLocation = function(protocol, params) {
); );
}; };
/**
* Get information on how a specific user on a third party protocol
* may be reached.
* @param {string} protocol The protocol given in getThirdpartyProtocols()
* @param {object} params Protocol-specific parameters, as given in the
* response to getThirdpartyProtocols()
* @return {module:client.Promise} Resolves to the result object
*/
MatrixBaseApis.prototype.getThirdpartyUser = function(protocol, params) {
const path = utils.encodeUri("/thirdparty/user/$protocol", {
$protocol: protocol,
});
return this._http.authedRequestWithPrefix(
undefined, "GET", path, params, undefined,
httpApi.PREFIX_UNSTABLE,
);
};
/** /**
* MatrixBaseApis object * MatrixBaseApis object
*/ */

View File

@@ -41,18 +41,46 @@ const SyncApi = require("./sync");
const MatrixBaseApis = require("./base-apis"); const MatrixBaseApis = require("./base-apis");
const MatrixError = httpApi.MatrixError; const MatrixError = httpApi.MatrixError;
const ContentHelpers = require("./content-helpers"); const ContentHelpers = require("./content-helpers");
const olmlib = require("./crypto/olmlib");
import ReEmitter from './ReEmitter'; import ReEmitter from './ReEmitter';
import RoomList from './crypto/RoomList'; import RoomList from './crypto/RoomList';
const SCROLLBACK_DELAY_MS = 3000; import Crypto from './crypto';
let CRYPTO_ENABLED = false; import { isCryptoAvailable } from './crypto';
import { encodeRecoveryKey, decodeRecoveryKey } from './crypto/recoverykey';
import { keyForNewBackup, keyForExistingBackup } from './crypto/backup_password';
import { randomString } from './randomstring';
try { // Disable warnings for now: we use deprecated bluebird functions
var Crypto = require("./crypto"); // and need to migrate, but they spam the console with warnings.
CRYPTO_ENABLED = true; Promise.config({warnings: false});
} catch (e) {
console.warn("Unable to load crypto module: crypto will be disabled: " + e);
const SCROLLBACK_DELAY_MS = 3000;
const CRYPTO_ENABLED = isCryptoAvailable();
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) {
console.log("Failed to decrypt session from backup");
}
}
return keys;
}
function keyFromRecoverySession(session, decryptionKey) {
return JSON.parse(decryptionKey.decrypt(
session.session_data.ephemeral,
session.session_data.mac,
session.session_data.ciphertext,
));
} }
/** /**
@@ -128,6 +156,8 @@ function MatrixClient(opts) {
MatrixBaseApis.call(this, opts); MatrixBaseApis.call(this, opts);
this.olmVersion = null; // Populated after initCrypto is done
this.reEmitter = new ReEmitter(this); this.reEmitter = new ReEmitter(this);
this.store = opts.store || new StubStore(); this.store = opts.store || new StubStore();
@@ -180,10 +210,6 @@ function MatrixClient(opts) {
this._forceTURN = opts.forceTURN || false; this._forceTURN = opts.forceTURN || false;
if (CRYPTO_ENABLED) {
this.olmVersion = Crypto.getOlmVersion();
}
// List of which rooms have encryption enabled: separate from crypto because // 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 still want to know which rooms are encrypted even if crypto is disabled:
// we don't want to start sending unencrypted events to them. // we don't want to start sending unencrypted events to them.
@@ -191,6 +217,8 @@ function MatrixClient(opts) {
// The pushprocessor caches useful things, so keep one and re-use it // The pushprocessor caches useful things, so keep one and re-use it
this._pushProcessor = new PushProcessor(this); this._pushProcessor = new PushProcessor(this);
this._serverSupportsLazyLoading = null;
} }
utils.inherits(MatrixClient, EventEmitter); utils.inherits(MatrixClient, EventEmitter);
utils.extend(MatrixClient.prototype, MatrixBaseApis.prototype); utils.extend(MatrixClient.prototype, MatrixBaseApis.prototype);
@@ -287,6 +315,21 @@ MatrixClient.prototype.getSyncState = function() {
return this._syncApi.getSyncState(); return this._syncApi.getSyncState();
}; };
/**
* Returns the additional data object associated with
* the current sync state, or null if there is no
* such data.
* Sync errors, if available, are put in the 'error' key of
* this object.
* @return {?Object}
*/
MatrixClient.prototype.getSyncStateData = function() {
if (!this._syncApi) {
return null;
}
return this._syncApi.getSyncStateData();
};
/** /**
* Return whether the client is configured for a guest account. * Return whether the client is configured for a guest account.
* @return {boolean} True if this is a guest access_token (or no token is supplied). * @return {boolean} True if this is a guest access_token (or no token is supplied).
@@ -356,6 +399,13 @@ MatrixClient.prototype.setNotifTimelineSet = function(notifTimelineSet) {
* successfully initialised. * successfully initialised.
*/ */
MatrixClient.prototype.initCrypto = async function() { 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) { if (this._crypto) {
console.warn("Attempt to re-initialise e2e encryption on MatrixClient"); console.warn("Attempt to re-initialise e2e encryption on MatrixClient");
return; return;
@@ -373,13 +423,6 @@ MatrixClient.prototype.initCrypto = async function() {
// initialise the list of encrypted rooms (whether or not crypto is enabled) // initialise the list of encrypted rooms (whether or not crypto is enabled)
await this._roomList.init(); await this._roomList.init();
if (!CRYPTO_ENABLED) {
throw new Error(
`End-to-end encryption not supported in this js-sdk build: did ` +
`you remember to load the olm library?`,
);
}
const userId = this.getUserId(); const userId = this.getUserId();
if (userId === null) { if (userId === null) {
throw new Error( throw new Error(
@@ -411,6 +454,9 @@ MatrixClient.prototype.initCrypto = async function() {
await crypto.init(); await crypto.init();
this.olmVersion = Crypto.getOlmVersion();
// if crypto initialisation was successful, tell it to attach its event // if crypto initialisation was successful, tell it to attach its event
// handlers. // handlers.
crypto.registerEventHandlers(this); crypto.registerEventHandlers(this);
@@ -514,7 +560,15 @@ MatrixClient.prototype.setDeviceVerified = function(userId, deviceId, verified)
if (verified === undefined) { if (verified === undefined) {
verified = true; verified = true;
} }
return _setDeviceVerification(this, userId, deviceId, verified, null); 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;
}; };
/** /**
@@ -673,6 +727,21 @@ MatrixClient.prototype.isRoomEncrypted = function(roomId) {
return this._roomList.isRoomEncrypted(roomId); 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 * Get a list containing all of the room keys
* *
@@ -703,6 +772,333 @@ MatrixClient.prototype.importRoomKeys = function(keys) {
return this._crypto.importRoomKeys(keys); return this._crypto.importRoomKeys(keys);
}; };
/**
* 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",
).then((res) => {
if (res.algorithm !== olmlib.MEGOLM_BACKUP_ALGORITHM) {
const err = "Unknown backup algorithm: " + res.algorithm;
return Promise.reject(err);
} else if (!(typeof res.auth_data === "object")
|| !res.auth_data.public_key) {
const err = "Invalid backup data returned";
return Promise.reject(err);
} else {
return res;
}
}).catch((e) => {
if (e.errcode === 'M_NOT_FOUND') {
return null;
} else {
throw e;
}
});
};
/**
* @param {object} info key backup info dict from getKeyBackupVersion()
* @return {object} {
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
* sigs: [
* valid: [bool],
* device: [DeviceInfo],
* ]
* }
*/
MatrixClient.prototype.isKeyBackupTrusted = function(info) {
return this._crypto.isKeyBackupTrusted(info);
};
/**
* @returns {bool} true if the client is configured to back up keys to
* the server, otherwise false.
*/
MatrixClient.prototype.getKeyBackupEnabled = function() {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
return Boolean(this._crypto.backupKey);
};
/**
* Enable backing up of keys, using data previously returned from
* getKeyBackupVersion.
*
* @param {object} info Backup information object as returned by getKeyBackupVersion
*/
MatrixClient.prototype.enableKeyBackup = function(info) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
this._crypto.backupInfo = info;
if (this._crypto.backupKey) this._crypto.backupKey.free();
this._crypto.backupKey = new global.Olm.PkEncryption();
this._crypto.backupKey.set_recipient_key(info.auth_data.public_key);
this.emit('crypto.keyBackupStatus', true);
};
/**
* Disable backing up of keys.
*/
MatrixClient.prototype.disableKeyBackup = function() {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
this._crypto.backupInfo = null;
if (this._crypto.backupKey) this._crypto.backupKey.free();
this._crypto.backupKey = null;
this.emit('crypto.keyBackupStatus', false);
};
/**
* Set up the data required to create a new backup version. The backup version
* will not be created and enabled until createKeyBackupVersion is called.
*
* @param {string} password Passphrase string that can be entered by the user
* when restoring the backup as an alternative to entering the recovery key.
* Optional.
*
* @returns {Promise<object>} Object that can be passed to createKeyBackupVersion and
* additionally has a 'recovery_key' member with the user-facing recovery key string.
*/
MatrixClient.prototype.prepareKeyBackupVersion = async function(password) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
const decryption = new global.Olm.PkDecryption();
try {
let publicKey;
const authData = {};
if (password) {
const keyInfo = await keyForNewBackup(password);
publicKey = decryption.init_with_private_key(keyInfo.key);
authData.private_key_salt = keyInfo.salt;
authData.private_key_iterations = keyInfo.iterations;
} else {
publicKey = decryption.generate_key();
}
authData.public_key = publicKey;
return {
algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
auth_data: authData,
recovery_key: encodeRecoveryKey(decryption.get_private_key()),
};
} finally {
decryption.free();
}
};
/**
* Create a new key backup version and enable it, using the information return
* from prepareKeyBackupVersion.
*
* @param {object} info Info object from prepareKeyBackupVersion
* @returns {Promise<object>} Object with 'version' param indicating the version created
*/
MatrixClient.prototype.createKeyBackupVersion = function(info) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
const data = {
algorithm: info.algorithm,
auth_data: info.auth_data,
};
return this._crypto._signObject(data.auth_data).then(() => {
return this._http.authedRequest(
undefined, "POST", "/room_keys/version", undefined, data,
);
}).then((res) => {
this.enableKeyBackup({
algorithm: info.algorithm,
auth_data: info.auth_data,
version: res.version,
});
return res;
});
};
MatrixClient.prototype.deleteKeyBackupVersion = function(version) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
// If we're currently backing up to this backup... stop.
// (We start using it automatically in createKeyBackupVersion
// so this is symmetrical).
if (this._crypto.backupInfo && this._crypto.backupInfo.version === version) {
this.disableKeyBackup();
}
const path = utils.encodeUri("/room_keys/version/$version", {
$version: version,
});
return this._http.authedRequest(
undefined, "DELETE", path, undefined, undefined,
);
};
MatrixClient.prototype._makeKeyBackupPath = function(roomId, sessionId, version) {
let path;
if (sessionId !== undefined) {
path = utils.encodeUri("/room_keys/keys/$roomId/$sessionId", {
$roomId: roomId,
$sessionId: sessionId,
});
} else if (roomId !== undefined) {
path = utils.encodeUri("/room_keys/keys/$roomId", {
$roomId: roomId,
});
} else {
path = "/room_keys/keys";
}
const queryData = version === undefined ? undefined : { version: version };
return {
path: path,
queryData: queryData,
};
};
/**
* Back up session keys to the homeserver.
* @param {string} roomId ID of the room that the keys are for Optional.
* @param {string} sessionId ID of the session that the keys are for Optional.
* @param {integer} version backup version Optional.
* @param {object} data Object keys to send
* @return {module:client.Promise} a promise that will resolve when the keys
* are uploaded
*/
MatrixClient.prototype.sendKeyBackup = function(roomId, sessionId, version, data) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
const path = this._makeKeyBackupPath(roomId, sessionId, version);
return this._http.authedRequest(
undefined, "PUT", path.path, path.queryData, data,
);
};
MatrixClient.prototype.backupAllGroupSessions = function(version) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
return this._crypto.backupAllGroupSessions(version);
};
MatrixClient.prototype.isValidRecoveryKey = function(recoveryKey) {
try {
decodeRecoveryKey(recoveryKey);
return true;
} catch (e) {
return false;
}
};
MatrixClient.prototype.restoreKeyBackupWithPassword = async function(
password, targetRoomId, targetSessionId, version,
) {
const backupInfo = await this.getKeyBackupVersion();
const privKey = await keyForExistingBackup(backupInfo, password);
return this._restoreKeyBackup(
privKey, targetRoomId, targetSessionId, version,
);
};
MatrixClient.prototype.restoreKeyBackupWithRecoveryKey = function(
recoveryKey, targetRoomId, targetSessionId, version,
) {
const privKey = decodeRecoveryKey(recoveryKey);
return this._restoreKeyBackup(
privKey, targetRoomId, targetSessionId, version,
);
};
MatrixClient.prototype._restoreKeyBackup = function(
privKey, targetRoomId, targetSessionId, version,
) {
if (this._crypto === null) {
throw new Error("End-to-end encryption disabled");
}
let totalKeyCount = 0;
let keys = [];
const path = this._makeKeyBackupPath(targetRoomId, targetSessionId, version);
const decryption = new global.Olm.PkDecryption();
try {
decryption.init_with_private_key(privKey);
} catch(e) {
decryption.free();
throw e;
}
return this._http.authedRequest(
undefined, "GET", path.path, path.queryData,
).then((res) => {
if (res.rooms) {
for (const [roomId, roomData] of Object.entries(res.rooms)) {
if (!roomData.sessions) continue;
totalKeyCount += Object.keys(roomData.sessions).length;
const roomKeys = keysFromRecoverySession(
roomData.sessions, decryption, roomId, roomKeys,
);
for (const k of roomKeys) {
k.room_id = roomId;
keys.push(k);
}
}
} else if (res.sessions) {
totalKeyCount = Object.keys(res.sessions).length;
keys = keysFromRecoverySession(
res.sessions, decryption, targetRoomId, keys,
);
} else {
totalKeyCount = 1;
try {
const key = keyFromRecoverySession(res, decryption);
key.room_id = targetRoomId;
key.session_id = targetSessionId;
keys.push(key);
} catch (e) {
console.log("Failed to decrypt session from backup");
}
}
return this.importRoomKeys(keys);
}).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,
);
};
// Group ops // Group ops
// ========= // =========
// Operations on groups that come down the sync stream (ie. ones the // Operations on groups that come down the sync stream (ie. ones the
@@ -727,6 +1123,17 @@ MatrixClient.prototype.getGroups = function() {
return this.store.getGroups(); return this.store.getGroups();
}; };
/**
* Get the config for the media repository.
* @param {module:client.callback} callback Optional.
* @return {module:client.Promise} Resolves with an object containing the config.
*/
MatrixClient.prototype.getMediaConfig = function(callback) {
return this._http.authedRequestWithPrefix(
callback, "GET", "/config", undefined, undefined, httpApi.PREFIX_MEDIA_R0,
);
};
// Room ops // Room ops
// ======== // ========
@@ -750,6 +1157,37 @@ MatrixClient.prototype.getRooms = function() {
return this.store.getRooms(); 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. * Retrieve a user.
* @param {string} userId The user ID to retrieve. * @param {string} userId The user ID to retrieve.
@@ -842,6 +1280,8 @@ MatrixClient.prototype.isUserIgnored = function(userId) {
* </strong> Default: true. * </strong> Default: true.
* @param {boolean} opts.inviteSignUrl If the caller has a keypair 3pid invite, * @param {boolean} opts.inviteSignUrl If the caller has a keypair 3pid invite,
* the signing URL is passed in this parameter. * 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. * @param {module:client.callback} callback Optional.
* @return {module:client.Promise} Resolves: Room object. * @return {module:client.Promise} Resolves: Room object.
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
@@ -870,6 +1310,13 @@ MatrixClient.prototype.joinRoom = function(roomIdOrAlias, opts, callback) {
); );
} }
const queryString = {};
if (opts.viaServers) {
queryString["server_name"] = opts.viaServers;
}
const reqOpts = {qsStringifyOptions: {arrayFormat: 'repeat'}};
const defer = Promise.defer(); const defer = Promise.defer();
const self = this; const self = this;
@@ -880,7 +1327,8 @@ MatrixClient.prototype.joinRoom = function(roomIdOrAlias, opts, callback) {
} }
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias}); const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias});
return self._http.authedRequest(undefined, "POST", path, undefined, data); return self._http.authedRequest(
undefined, "POST", path, queryString, data, reqOpts);
}).then(function(res) { }).then(function(res) {
const roomId = res.room_id; const roomId = res.room_id;
const syncApi = new SyncApi(self, self._clientOpts); const syncApi = new SyncApi(self, self._clientOpts);
@@ -1100,6 +1548,13 @@ MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId,
room.addPendingEvent(localEvent, txnId); 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); return _sendEvent(this, room, localEvent, callback);
}; };
@@ -1807,6 +2262,27 @@ MatrixClient.prototype.mxcUrlToHttp =
); );
}; };
/**
* Sets a new status message for the user. The message may be null/falsey
* to clear the message.
* @param {string} newMessage The new message to set.
* @return {module:client.Promise} Resolves: to nothing
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixClient.prototype._unstable_setStatusMessage = function(newMessage) {
return Promise.all(this.getRooms().map((room) => {
const isJoined = room.getMyMembership() === "join";
const looksLikeDm = room.getInvitedAndJoinedMemberCount() === 2;
if (isJoined && looksLikeDm) {
return this.sendStateEvent(room.roomId, "im.vector.user_status", {
status: newMessage,
}, this.getUserId());
} else {
return Promise.resolve();
}
}));
};
/** /**
* @param {Object} opts Options to apply * @param {Object} opts Options to apply
* @param {string} opts.presence One of "online", "offline" or "unavailable" * @param {string} opts.presence One of "online", "offline" or "unavailable"
@@ -1919,14 +2395,6 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) {
// reduce the required number of events appropriately // reduce the required number of events appropriately
limit = limit - numAdded; limit = limit - numAdded;
const path = utils.encodeUri(
"/rooms/$roomId/messages", {$roomId: room.roomId},
);
const params = {
from: room.oldState.paginationToken,
limit: limit,
dir: 'b',
};
const defer = Promise.defer(); const defer = Promise.defer();
info = { info = {
promise: defer.promise, promise: defer.promise,
@@ -1936,9 +2404,17 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) {
// wait for a time before doing this request // wait for a time before doing this request
// (which may be 0 in order not to special case the code paths) // (which may be 0 in order not to special case the code paths)
Promise.delay(timeToWaitMs).then(function() { Promise.delay(timeToWaitMs).then(function() {
return self._http.authedRequest(callback, "GET", path, params); return self._createMessagesRequest(
room.roomId,
room.oldState.paginationToken,
limit,
'b');
}).done(function(res) { }).done(function(res) {
const matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self)); 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.addEventsToTimeline(matrixEvents, true, room.getLiveTimeline());
room.oldState.paginationToken = res.end; room.oldState.paginationToken = res.end;
if (res.chunk.length === 0) { if (res.chunk.length === 0) {
@@ -1957,73 +2433,6 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) {
return defer.promise; return defer.promise;
}; };
/**
* Take an EventContext, and back/forward-fill results.
*
* @param {module:models/event-context.EventContext} eventContext context
* object to be updated
* @param {Object} opts
* @param {boolean} opts.backwards true to fill backwards, false to go forwards
* @param {boolean} opts.limit number of events to request
*
* @return {module:client.Promise} Resolves: updated EventContext object
* @return {Error} Rejects: with an error response.
*/
MatrixClient.prototype.paginateEventContext = function(eventContext, opts) {
// TODO: we should implement a backoff (as per scrollback()) to deal more
// nicely with HTTP errors.
opts = opts || {};
const backwards = opts.backwards || false;
const token = eventContext.getPaginateToken(backwards);
if (!token) {
// no more results.
return Promise.reject(new Error("No paginate token"));
}
const dir = backwards ? 'b' : 'f';
const pendingRequest = eventContext._paginateRequests[dir];
if (pendingRequest) {
// already a request in progress - return the existing promise
return pendingRequest;
}
const path = utils.encodeUri(
"/rooms/$roomId/messages", {$roomId: eventContext.getEvent().getRoomId()},
);
const params = {
from: token,
limit: ('limit' in opts) ? opts.limit : 30,
dir: dir,
};
const self = this;
const promise =
self._http.authedRequest(undefined, "GET", path, params,
).then(function(res) {
let token = res.end;
if (res.chunk.length === 0) {
token = null;
} else {
const matrixEvents = utils.map(res.chunk, self.getEventMapper());
if (backwards) {
// eventContext expects the events in timeline order, but
// back-pagination returns them in reverse order.
matrixEvents.reverse();
}
eventContext.addEvents(matrixEvents, backwards);
}
eventContext.setPaginateToken(token, backwards);
return eventContext;
}).finally(function() {
eventContext._paginateRequests[dir] = null;
});
eventContext._paginateRequests[dir] = promise;
return promise;
};
/** /**
* Get an EventTimeline for the given event * Get an EventTimeline for the given event
* *
@@ -2057,11 +2466,16 @@ MatrixClient.prototype.getEventTimeline = function(timelineSet, 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 // TODO: we should implement a backoff (as per scrollback()) to deal more
// nicely with HTTP errors. // nicely with HTTP errors.
const self = this; const self = this;
const promise = const promise =
self._http.authedRequest(undefined, "GET", path, self._http.authedRequest(undefined, "GET", path, params,
).then(function(res) { ).then(function(res) {
if (!res.event) { if (!res.event) {
throw new Error("'event' not in '/context' result - homeserver too old?"); throw new Error("'event' not in '/context' result - homeserver too old?");
@@ -2088,6 +2502,9 @@ MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) {
timeline.initialiseState(utils.map(res.state, timeline.initialiseState(utils.map(res.state,
self.getEventMapper())); self.getEventMapper()));
timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end; 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); timelineSet.addEventsToTimeline(matrixEvents, true, timeline, res.start);
@@ -2102,6 +2519,49 @@ MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) {
return promise; 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. * Take an EventTimeline, and back/forward-fill results.
@@ -2196,25 +2656,18 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) {
throw new Error("Unknown room " + eventTimeline.getRoomId()); throw new Error("Unknown room " + eventTimeline.getRoomId());
} }
path = utils.encodeUri( promise = this._createMessagesRequest(
"/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()}, eventTimeline.getRoomId(),
); token,
params = { opts.limit,
from: token, dir,
limit: ('limit' in opts) ? opts.limit : 30, eventTimeline.getFilter());
dir: dir, promise.then(function(res) {
}; if (res.state) {
const roomState = eventTimeline.getState(dir);
const filter = eventTimeline.getFilter(); const stateEvents = utils.map(res.state, self.getEventMapper());
if (filter) { roomState.setUnknownStateEvents(stateEvents);
// XXX: it's horrific that /messages' filter parameter doesn't match
// /sync's one - see https://matrix.org/jira/browse/SPEC-451
params.filter = JSON.stringify(filter.getRoomTimelineFilterComponent());
} }
promise =
this._http.authedRequest(undefined, "GET", path, params,
).then(function(res) {
const token = res.end; const token = res.end;
const matrixEvents = utils.map(res.chunk, self.getEventMapper()); const matrixEvents = utils.map(res.chunk, self.getEventMapper());
eventTimeline.getTimelineSet() eventTimeline.getTimelineSet()
@@ -3019,8 +3472,11 @@ MatrixClient.prototype.getTurnServers = function() {
* *
* @param {Boolean=} opts.disablePresence True to perform syncing without automatically * @param {Boolean=} opts.disablePresence True to perform syncing without automatically
* updating presence. * updating presence.
* @param {Boolean=} opts.lazyLoadMembers True to not load all membership events during
* initial sync but fetch them when needed by calling `loadOutOfBandMembers`
* This will override the filter option at this moment.
*/ */
MatrixClient.prototype.startClient = function(opts) { MatrixClient.prototype.startClient = async function(opts) {
if (this.clientRunning) { if (this.clientRunning) {
// client is already running. // client is already running.
return; return;
@@ -3058,11 +3514,29 @@ MatrixClient.prototype.startClient = function(opts) {
return this._canResetTimelineCallback(roomId); return this._canResetTimelineCallback(roomId);
}; };
this._clientOpts = opts; this._clientOpts = opts;
this._syncApi = new SyncApi(this, opts); this._syncApi = new SyncApi(this, opts);
this._syncApi.sync(); this._syncApi.sync();
}; };
/**
* store client options with boolean/string/numeric values
* to know in the next session what flags the sync data was
* created with (e.g. lazy loading)
* @param {object} opts the complete set of client options
* @return {Promise} for store operation */
MatrixClient.prototype._storeClientOptions = function() {
const primTypes = ["boolean", "string", "number"];
const serializableOpts = Object.entries(this._clientOpts)
.filter(([key, value]) => {
return primTypes.includes(typeof value);
})
.reduce((obj, [key, value]) => {
obj[key] = value;
return obj;
}, {});
return this.store.storeClientOptions(serializableOpts);
};
/** /**
* High level helper method to stop the client from polling and allow a * High level helper method to stop the client from polling and allow a
* clean shutdown. * clean shutdown.
@@ -3085,6 +3559,36 @@ MatrixClient.prototype.stopClient = function() {
global.clearTimeout(this._checkTurnServersTimeoutID); global.clearTimeout(this._checkTurnServersTimeoutID);
}; };
/*
* Query the server to see if it support members lazy loading
* @return {Promise<boolean>} true if server supports lazy loading
*/
MatrixClient.prototype.doesServerSupportLazyLoading = async function() {
if (this._serverSupportsLazyLoading === null) {
const response = await this._http.request(
undefined, // callback
"GET", "/_matrix/client/versions",
undefined, // queryParams
undefined, // data
{
prefix: '',
},
);
const unstableFeatures = response["unstable_features"];
this._serverSupportsLazyLoading =
unstableFeatures && unstableFeatures["m.lazy_load_members"];
}
return this._serverSupportsLazyLoading;
};
/*
* Get if lazy loading members is being used.
* @return {boolean} Whether or not members are lazy loaded by this client
*/
MatrixClient.prototype.hasLazyLoadMembersEnabled = function() {
return !!this._clientOpts.lazyLoadMembers;
};
/* /*
* Set a function which is called when /sync returns a 'limited' response. * 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 * It is called with a room ID and returns a boolean. It should return 'true' if the SDK
@@ -3380,14 +3884,7 @@ MatrixClient.prototype.getEventMapper = function() {
* @return {string} A new client secret * @return {string} A new client secret
*/ */
MatrixClient.prototype.generateClientSecret = function() { MatrixClient.prototype.generateClientSecret = function() {
let ret = ""; return randomString(32);
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < 32; i++) {
ret += chars.charAt(Math.floor(Math.random() * chars.length));
}
return ret;
}; };
/** */ /** */
@@ -3430,6 +3927,12 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
* a state of SYNCING. <i>This is the equivalent of "syncComplete" in the * a state of SYNCING. <i>This is the equivalent of "syncComplete" in the
* previous API.</i></li> * previous API.</i></li>
* *
* <li>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.</li>
*
* <li>SYNCING : The client is currently polling for new events from the server. * <li>SYNCING : The client is currently polling for new events from the server.
* This will be called <i>after</i> processing latest events from a sync.</li> * This will be called <i>after</i> processing latest events from a sync.</li>
* *
@@ -3454,10 +3957,10 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
* | * |
* +----->PREPARED -------> SYNCING <--+ * +----->PREPARED -------> SYNCING <--+
* | ^ | ^ | * | ^ | ^ |
* | | | | | * | CATCHUP ----------+ | | |
* | | V | | * | ^ V | |
* null ------+ | +--------RECONNECTING | * null ------+ | +------- RECONNECTING |
* | | V | * | V V |
* +------->ERROR ---------------------+ * +------->ERROR ---------------------+
* *
* NB: 'null' will never be emitted by this event. * NB: 'null' will never be emitted by this event.
@@ -3507,7 +4010,7 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
* *
* @param {?Object} data Data about this transition. * @param {?Object} data Data about this transition.
* *
* @param {MatrixError} data.err The matrix error if <code>state=ERROR</code>. * @param {MatrixError} data.error The matrix error if <code>state=ERROR</code>.
* *
* @param {String} data.oldSyncToken The 'since' token passed to /sync. * @param {String} data.oldSyncToken The 'since' token passed to /sync.
* <code>null</code> for the first successful sync since this client was * <code>null</code> for the first successful sync since this client was
@@ -3625,6 +4128,24 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
* }); * });
*/ */
/**
* 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"
*/
// EventEmitter JSDocs // EventEmitter JSDocs

View File

@@ -24,6 +24,7 @@ limitations under the License.
import Promise from 'bluebird'; import Promise from 'bluebird';
import logger from '../logger';
import DeviceInfo from './deviceinfo'; import DeviceInfo from './deviceinfo';
import olmlib from './olmlib'; import olmlib from './olmlib';
import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
@@ -71,6 +72,9 @@ export default class DeviceList {
// } // }
this._devices = {}; this._devices = {};
// map of identity keys to the user who owns it
this._userByIdentityKey = {};
// which users we are tracking device status for. // which users we are tracking device status for.
// userId -> TRACKING_STATUS_* // userId -> TRACKING_STATUS_*
this._deviceTrackingStatus = {}; // loaded from storage in load() this._deviceTrackingStatus = {}; // loaded from storage in load()
@@ -110,7 +114,7 @@ export default class DeviceList {
'readwrite', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { 'readwrite', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
this._cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { this._cryptoStore.getEndToEndDeviceData(txn, (deviceData) => {
if (deviceData === null) { if (deviceData === null) {
console.log("Migrating e2e device data..."); logger.log("Migrating e2e device data...");
this._devices = this._sessionStore.getAllEndToEndDevices() || {}; this._devices = this._sessionStore.getAllEndToEndDevices() || {};
this._deviceTrackingStatus = ( this._deviceTrackingStatus = (
this._sessionStore.getEndToEndDeviceTrackingStatus() || {} this._sessionStore.getEndToEndDeviceTrackingStatus() || {}
@@ -128,6 +132,16 @@ export default class DeviceList {
deviceData.trackingStatus : {}; deviceData.trackingStatus : {};
this._syncToken = deviceData ? deviceData.syncToken : null; 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;
}
}
}
}); });
}, },
); );
@@ -145,6 +159,12 @@ export default class DeviceList {
} }
} }
stop() {
if (this._saveTimer !== null) {
clearTimeout(this._saveTimer);
}
}
/** /**
* Save the device tracking state to storage, if any changes are * Save the device tracking state to storage, if any changes are
* pending other than updating the sync token * pending other than updating the sync token
@@ -190,7 +210,7 @@ export default class DeviceList {
const resolveSavePromise = this._resolveSavePromise; const resolveSavePromise = this._resolveSavePromise;
this._savePromiseTime = targetTime; this._savePromiseTime = targetTime;
this._saveTimer = setTimeout(() => { this._saveTimer = setTimeout(() => {
console.log('Saving device tracking data at token ' + this._syncToken); logger.log('Saving device tracking data at token ' + this._syncToken);
// null out savePromise now (after the delay but before the write), // null out savePromise now (after the delay but before the write),
// otherwise we could return the existing promise when the save has // otherwise we could return the existing promise when the save has
// actually already happened. Likewise for the dirty flag. // actually already happened. Likewise for the dirty flag.
@@ -258,7 +278,7 @@ export default class DeviceList {
if (this._keyDownloadsInProgressByUser[u]) { if (this._keyDownloadsInProgressByUser[u]) {
// already a key download in progress/queued for this user; its results // already a key download in progress/queued for this user; its results
// will be good enough for us. // will be good enough for us.
console.log( logger.log(
`downloadKeys: already have a download in progress for ` + `downloadKeys: already have a download in progress for ` +
`${u}: awaiting its result`, `${u}: awaiting its result`,
); );
@@ -269,13 +289,13 @@ export default class DeviceList {
}); });
if (usersToDownload.length != 0) { if (usersToDownload.length != 0) {
console.log("downloadKeys: downloading for", usersToDownload); logger.log("downloadKeys: downloading for", usersToDownload);
const downloadPromise = this._doKeyDownload(usersToDownload); const downloadPromise = this._doKeyDownload(usersToDownload);
promises.push(downloadPromise); promises.push(downloadPromise);
} }
if (promises.length === 0) { if (promises.length === 0) {
console.log("downloadKeys: already have all necessary keys"); logger.log("downloadKeys: already have all necessary keys");
} }
return Promise.all(promises).then(() => { return Promise.all(promises).then(() => {
@@ -357,13 +377,17 @@ export default class DeviceList {
/** /**
* Find a device by curve25519 identity key * Find a device by curve25519 identity key
* *
* @param {string} userId owner of the device
* @param {string} algorithm encryption algorithm * @param {string} algorithm encryption algorithm
* @param {string} senderKey curve25519 key to match * @param {string} senderKey curve25519 key to match
* *
* @return {module:crypto/deviceinfo?} * @return {module:crypto/deviceinfo?}
*/ */
getDeviceByIdentityKey(userId, algorithm, senderKey) { getDeviceByIdentityKey(algorithm, senderKey) {
const userId = this._userByIdentityKey[senderKey];
if (!userId) {
return null;
}
if ( if (
algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.OLM_ALGORITHM &&
algorithm !== olmlib.MEGOLM_ALGORITHM algorithm !== olmlib.MEGOLM_ALGORITHM
@@ -408,7 +432,23 @@ export default class DeviceList {
* @param {Object} devs New device info for user * @param {Object} devs New device info for user
*/ */
storeDevicesForUser(u, devs) { 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; 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; this._dirty = true;
} }
@@ -433,7 +473,7 @@ export default class DeviceList {
throw new Error('userId must be a string; was '+userId); throw new Error('userId must be a string; was '+userId);
} }
if (!this._deviceTrackingStatus[userId]) { if (!this._deviceTrackingStatus[userId]) {
console.log('Now tracking device list for ' + userId); logger.log('Now tracking device list for ' + userId);
this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD; this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD;
} }
// we don't yet persist the tracking status, since there may be a lot // we don't yet persist the tracking status, since there may be a lot
@@ -452,7 +492,7 @@ export default class DeviceList {
*/ */
stopTrackingDeviceList(userId) { stopTrackingDeviceList(userId) {
if (this._deviceTrackingStatus[userId]) { if (this._deviceTrackingStatus[userId]) {
console.log('No longer tracking device list for ' + userId); logger.log('No longer tracking device list for ' + userId);
this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED; this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED;
// we don't yet persist the tracking status, since there may be a lot // we don't yet persist the tracking status, since there may be a lot
@@ -487,7 +527,7 @@ export default class DeviceList {
*/ */
invalidateUserDeviceList(userId) { invalidateUserDeviceList(userId) {
if (this._deviceTrackingStatus[userId]) { if (this._deviceTrackingStatus[userId]) {
console.log("Marking device list outdated for", userId); logger.log("Marking device list outdated for", userId);
this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD; this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD;
// we don't yet persist the tracking status, since there may be a lot // we don't yet persist the tracking status, since there may be a lot
@@ -525,7 +565,23 @@ export default class DeviceList {
* @param {Object} devices deviceId->{object} the new devices * @param {Object} devices deviceId->{object} the new devices
*/ */
_setRawStoredDevicesForUser(userId, 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; 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;
}
} }
/** /**
@@ -550,7 +606,7 @@ export default class DeviceList {
).then(() => { ).then(() => {
finished(true); finished(true);
}, (e) => { }, (e) => {
console.error( logger.error(
'Error downloading keys for ' + users + ":", e, 'Error downloading keys for ' + users + ":", e,
); );
finished(false); finished(false);
@@ -573,7 +629,7 @@ export default class DeviceList {
// since we started this request. If that happens, we should // since we started this request. If that happens, we should
// ignore the completion of the first one. // ignore the completion of the first one.
if (this._keyDownloadsInProgressByUser[u] !== prom) { if (this._keyDownloadsInProgressByUser[u] !== prom) {
console.log('Another update in the queue for', u, logger.log('Another update in the queue for', u,
'- not marking up-to-date'); '- not marking up-to-date');
return; return;
} }
@@ -584,7 +640,7 @@ export default class DeviceList {
// we didn't get any new invalidations since this download started: // we didn't get any new invalidations since this download started:
// this user's device list is now up to date. // this user's device list is now up to date.
this._deviceTrackingStatus[u] = TRACKING_STATUS_UP_TO_DATE; this._deviceTrackingStatus[u] = TRACKING_STATUS_UP_TO_DATE;
console.log("Device list for", u, "now up to date"); logger.log("Device list for", u, "now up to date");
} else { } else {
this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD; this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD;
} }
@@ -659,7 +715,7 @@ class DeviceListUpdateSerialiser {
if (this._downloadInProgress) { if (this._downloadInProgress) {
// just queue up these users // just queue up these users
console.log('Queued key download for', users); logger.log('Queued key download for', users);
return this._queuedQueryDeferred.promise; return this._queuedQueryDeferred.promise;
} }
@@ -679,7 +735,7 @@ class DeviceListUpdateSerialiser {
const deferred = this._queuedQueryDeferred; const deferred = this._queuedQueryDeferred;
this._queuedQueryDeferred = null; this._queuedQueryDeferred = null;
console.log('Starting key download for', downloadUsers); logger.log('Starting key download for', downloadUsers);
this._downloadInProgress = true; this._downloadInProgress = true;
const opts = {}; const opts = {};
@@ -706,7 +762,7 @@ class DeviceListUpdateSerialiser {
return prom; return prom;
}).done(() => { }).done(() => {
console.log('Completed key download for ' + downloadUsers); logger.log('Completed key download for ' + downloadUsers);
this._downloadInProgress = false; this._downloadInProgress = false;
deferred.resolve(); deferred.resolve();
@@ -716,7 +772,7 @@ class DeviceListUpdateSerialiser {
this._doQueuedQueries(); this._doQueuedQueries();
} }
}, (e) => { }, (e) => {
console.warn('Error downloading keys for ' + downloadUsers + ':', e); logger.warn('Error downloading keys for ' + downloadUsers + ':', e);
this._downloadInProgress = false; this._downloadInProgress = false;
deferred.reject(e); deferred.reject(e);
}); });
@@ -725,7 +781,7 @@ class DeviceListUpdateSerialiser {
} }
async _processQueryResponseForUser(userId, response) { async _processQueryResponseForUser(userId, response) {
console.log('got keys for ' + userId + ':', response); logger.log('got keys for ' + userId + ':', response);
// map from deviceid -> deviceinfo for this user // map from deviceid -> deviceinfo for this user
const userStore = {}; const userStore = {};
@@ -763,7 +819,7 @@ async function _updateStoredDeviceKeysForUser(_olmDevice, userId, userStore,
} }
if (!(deviceId in userResult)) { if (!(deviceId in userResult)) {
console.log("Device " + userId + ":" + deviceId + logger.log("Device " + userId + ":" + deviceId +
" has been removed"); " has been removed");
delete userStore[deviceId]; delete userStore[deviceId];
updated = true; updated = true;
@@ -780,12 +836,12 @@ async function _updateStoredDeviceKeysForUser(_olmDevice, userId, userStore,
// check that the user_id and device_id in the response object are // check that the user_id and device_id in the response object are
// correct // correct
if (deviceResult.user_id !== userId) { if (deviceResult.user_id !== userId) {
console.warn("Mismatched user_id " + deviceResult.user_id + logger.warn("Mismatched user_id " + deviceResult.user_id +
" in keys from " + userId + ":" + deviceId); " in keys from " + userId + ":" + deviceId);
continue; continue;
} }
if (deviceResult.device_id !== deviceId) { if (deviceResult.device_id !== deviceId) {
console.warn("Mismatched device_id " + deviceResult.device_id + logger.warn("Mismatched device_id " + deviceResult.device_id +
" in keys from " + userId + ":" + deviceId); " in keys from " + userId + ":" + deviceId);
continue; continue;
} }
@@ -815,7 +871,7 @@ async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) {
const signKeyId = "ed25519:" + deviceId; const signKeyId = "ed25519:" + deviceId;
const signKey = deviceResult.keys[signKeyId]; const signKey = deviceResult.keys[signKeyId];
if (!signKey) { if (!signKey) {
console.warn("Device " + userId + ":" + deviceId + logger.warn("Device " + userId + ":" + deviceId +
" has no ed25519 key"); " has no ed25519 key");
return false; return false;
} }
@@ -825,7 +881,7 @@ async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) {
try { try {
await olmlib.verifySignature(_olmDevice, deviceResult, userId, deviceId, signKey); await olmlib.verifySignature(_olmDevice, deviceResult, userId, deviceId, signKey);
} catch (e) { } catch (e) {
console.warn("Unable to verify signature on device " + logger.warn("Unable to verify signature on device " +
userId + ":" + deviceId + ":" + e); userId + ":" + deviceId + ":" + e);
return false; return false;
} }
@@ -842,7 +898,7 @@ async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) {
// best off sticking with the original keys. // best off sticking with the original keys.
// //
// Should we warn the user about it somehow? // Should we warn the user about it somehow?
console.warn("Ed25519 key for device " + userId + ":" + logger.warn("Ed25519 key for device " + userId + ":" +
deviceId + " has changed"); deviceId + " has changed");
return false; return false;
} }

View File

@@ -15,19 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import logger from '../logger';
import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
/**
* olm.js wrapper
*
* @module crypto/OlmDevice
*/
const Olm = global.Olm;
if (!Olm) {
throw new Error("global.Olm is not defined");
}
// The maximum size of an event is 65K, and we base64 the content, so this is a // 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. // reasonable approximation to the biggest plaintext we can encrypt.
const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4; const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4;
@@ -127,7 +117,7 @@ OlmDevice.prototype.init = async function() {
await this._migrateFromSessionStore(); await this._migrateFromSessionStore();
let e2eKeys; let e2eKeys;
const account = new Olm.Account(); const account = new global.Olm.Account();
try { try {
await _initialiseAccount( await _initialiseAccount(
this._sessionStore, this._cryptoStore, this._pickleKey, account, this._sessionStore, this._cryptoStore, this._pickleKey, account,
@@ -161,7 +151,7 @@ async function _initialiseAccount(sessionStore, cryptoStore, pickleKey, account)
* @return {array} The version of Olm. * @return {array} The version of Olm.
*/ */
OlmDevice.getOlmVersion = function() { OlmDevice.getOlmVersion = function() {
return Olm.get_library_version(); return global.Olm.get_library_version();
}; };
OlmDevice.prototype._migrateFromSessionStore = async function() { OlmDevice.prototype._migrateFromSessionStore = async function() {
@@ -173,7 +163,7 @@ OlmDevice.prototype._migrateFromSessionStore = async function() {
// Migrate from sessionStore // Migrate from sessionStore
pickledAccount = this._sessionStore.getEndToEndAccount(); pickledAccount = this._sessionStore.getEndToEndAccount();
if (pickledAccount !== null) { if (pickledAccount !== null) {
console.log("Migrating account from session store"); logger.log("Migrating account from session store");
this._cryptoStore.storeAccount(txn, pickledAccount); this._cryptoStore.storeAccount(txn, pickledAccount);
} }
} }
@@ -195,7 +185,7 @@ OlmDevice.prototype._migrateFromSessionStore = async function() {
// has run against the same localstorage and created some spurious sessions. // has run against the same localstorage and created some spurious sessions.
this._cryptoStore.countEndToEndSessions(txn, (count) => { this._cryptoStore.countEndToEndSessions(txn, (count) => {
if (count) { if (count) {
console.log("Crypto store already has sessions: not migrating"); logger.log("Crypto store already has sessions: not migrating");
return; return;
} }
let numSessions = 0; let numSessions = 0;
@@ -207,7 +197,7 @@ OlmDevice.prototype._migrateFromSessionStore = async function() {
); );
} }
} }
console.log( logger.log(
"Migrating " + numSessions + " sessions from session store", "Migrating " + numSessions + " sessions from session store",
); );
}); });
@@ -236,14 +226,14 @@ OlmDevice.prototype._migrateFromSessionStore = async function() {
), txn, ), txn,
); );
} catch (e) { } catch (e) {
console.warn( logger.warn(
"Failed to migrate session " + s.senderKey + "/" + "Failed to migrate session " + s.senderKey + "/" +
s.sessionId + ": " + e.stack || e, s.sessionId + ": " + e.stack || e,
); );
} }
++numIbSessions; ++numIbSessions;
} }
console.log( logger.log(
"Migrated " + numIbSessions + "Migrated " + numIbSessions +
" inbound group sessions from session store", " inbound group sessions from session store",
); );
@@ -268,7 +258,7 @@ OlmDevice.prototype._migrateFromSessionStore = async function() {
*/ */
OlmDevice.prototype._getAccount = function(txn, func) { OlmDevice.prototype._getAccount = function(txn, func) {
this._cryptoStore.getAccount(txn, (pickledAccount) => { this._cryptoStore.getAccount(txn, (pickledAccount) => {
const account = new Olm.Account(); const account = new global.Olm.Account();
try { try {
account.unpickle(this._pickleKey, pickledAccount); account.unpickle(this._pickleKey, pickledAccount);
func(account); func(account);
@@ -305,8 +295,8 @@ OlmDevice.prototype._storeAccount = function(txn, account) {
*/ */
OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) { OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) {
this._cryptoStore.getEndToEndSession( this._cryptoStore.getEndToEndSession(
deviceKey, sessionId, txn, (pickledSession) => { deviceKey, sessionId, txn, (sessionInfo) => {
this._unpickleSession(pickledSession, func); this._unpickleSession(sessionInfo, func);
}, },
); );
}; };
@@ -316,15 +306,17 @@ OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) {
* function with it. The session object is destroyed once the function * function with it. The session object is destroyed once the function
* returns. * returns.
* *
* @param {string} pickledSession * @param {object} sessionInfo
* @param {function} func * @param {function} func
* @private * @private
*/ */
OlmDevice.prototype._unpickleSession = function(pickledSession, func) { OlmDevice.prototype._unpickleSession = function(sessionInfo, func) {
const session = new Olm.Session(); const session = new global.Olm.Session();
try { try {
session.unpickle(this._pickleKey, pickledSession); session.unpickle(this._pickleKey, sessionInfo.session);
func(session); const unpickledSessInfo = Object.assign({}, sessionInfo, {session});
func(unpickledSessInfo);
} finally { } finally {
session.free(); session.free();
} }
@@ -334,14 +326,17 @@ OlmDevice.prototype._unpickleSession = function(pickledSession, func) {
* store our OlmSession in the session store * store our OlmSession in the session store
* *
* @param {string} deviceKey * @param {string} deviceKey
* @param {OlmSession} session * @param {object} sessionInfo {session: OlmSession, lastReceivedMessageTs: int}
* @param {*} txn Opaque transaction object from cryptoStore.doTxn() * @param {*} txn Opaque transaction object from cryptoStore.doTxn()
* @private * @private
*/ */
OlmDevice.prototype._saveSession = function(deviceKey, session, txn) { OlmDevice.prototype._saveSession = function(deviceKey, sessionInfo, txn) {
const pickledSession = session.pickle(this._pickleKey); const sessionId = sessionInfo.session.session_id();
const pickledSessionInfo = Object.assign(sessionInfo, {
session: sessionInfo.session.pickle(this._pickleKey),
});
this._cryptoStore.storeEndToEndSession( this._cryptoStore.storeEndToEndSession(
deviceKey, session.session_id(), pickledSession, txn, deviceKey, sessionId, pickledSessionInfo, txn,
); );
}; };
@@ -354,7 +349,7 @@ OlmDevice.prototype._saveSession = function(deviceKey, session, txn) {
* @private * @private
*/ */
OlmDevice.prototype._getUtility = function(func) { OlmDevice.prototype._getUtility = function(func) {
const utility = new Olm.Utility(); const utility = new global.Olm.Utility();
try { try {
return func(utility); return func(utility);
} finally { } finally {
@@ -466,12 +461,19 @@ OlmDevice.prototype.createOutboundSession = async function(
], ],
(txn) => { (txn) => {
this._getAccount(txn, (account) => { this._getAccount(txn, (account) => {
const session = new Olm.Session(); const session = new global.Olm.Session();
try { try {
session.create_outbound(account, theirIdentityKey, theirOneTimeKey); session.create_outbound(account, theirIdentityKey, theirOneTimeKey);
newSessionId = session.session_id(); newSessionId = session.session_id();
this._storeAccount(txn, account); this._storeAccount(txn, account);
this._saveSession(theirIdentityKey, session, txn); 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 { } finally {
session.free(); session.free();
} }
@@ -510,7 +512,7 @@ OlmDevice.prototype.createInboundSession = async function(
], ],
(txn) => { (txn) => {
this._getAccount(txn, (account) => { this._getAccount(txn, (account) => {
const session = new Olm.Session(); const session = new global.Olm.Session();
try { try {
session.create_inbound_from( session.create_inbound_from(
account, theirDeviceIdentityKey, ciphertext, account, theirDeviceIdentityKey, ciphertext,
@@ -520,7 +522,13 @@ OlmDevice.prototype.createInboundSession = async function(
const payloadString = session.decrypt(messageType, ciphertext); const payloadString = session.decrypt(messageType, ciphertext);
this._saveSession(theirDeviceIdentityKey, session, txn); 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 = { result = {
payload: payloadString, payload: payloadString,
@@ -568,13 +576,30 @@ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityK
* @return {Promise<?string>} session id, or null if no established session * @return {Promise<?string>} session id, or null if no established session
*/ */
OlmDevice.prototype.getSessionIdForDevice = async function(theirDeviceIdentityKey) { OlmDevice.prototype.getSessionIdForDevice = async function(theirDeviceIdentityKey) {
const sessionIds = await this.getSessionIdsForDevice(theirDeviceIdentityKey); const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey);
if (sessionIds.length === 0) { if (sessionInfos.length === 0) {
return null; return null;
} }
// Use the session with the lowest ID. // Use the session that has most recently received a message
sessionIds.sort(); let idxOfBest = 0;
return sessionIds[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;
}; };
/** /**
@@ -597,9 +622,10 @@ OlmDevice.prototype.getSessionInfoForDevice = async function(deviceIdentityKey)
this._cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => { this._cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => {
const sessionIds = Object.keys(sessions).sort(); const sessionIds = Object.keys(sessions).sort();
for (const sessionId of sessionIds) { for (const sessionId of sessionIds) {
this._unpickleSession(sessions[sessionId], (session) => { this._unpickleSession(sessions[sessionId], (sessInfo) => {
info.push({ info.push({
hasReceivedMessage: session.has_received_message(), lastReceivedMessageTs: sessInfo.lastReceivedMessageTs,
hasReceivedMessage: sessInfo.session.has_received_message(),
sessionId: sessionId, sessionId: sessionId,
}); });
}); });
@@ -630,9 +656,9 @@ OlmDevice.prototype.encryptMessage = async function(
await this._cryptoStore.doTxn( await this._cryptoStore.doTxn(
'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS], 'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS],
(txn) => { (txn) => {
this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => { this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => {
res = session.encrypt(payloadString); res = sessionInfo.session.encrypt(payloadString);
this._saveSession(theirDeviceIdentityKey, session, txn); this._saveSession(theirDeviceIdentityKey, sessionInfo, txn);
}); });
}, },
); );
@@ -657,9 +683,10 @@ OlmDevice.prototype.decryptMessage = async function(
await this._cryptoStore.doTxn( await this._cryptoStore.doTxn(
'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS], 'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS],
(txn) => { (txn) => {
this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => { this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => {
payloadString = session.decrypt(messageType, ciphertext); payloadString = sessionInfo.session.decrypt(messageType, ciphertext);
this._saveSession(theirDeviceIdentityKey, session, txn); sessionInfo.lastReceivedMessageTs = Date.now();
this._saveSession(theirDeviceIdentityKey, sessionInfo, txn);
}); });
}, },
); );
@@ -689,8 +716,8 @@ OlmDevice.prototype.matchesSession = async function(
await this._cryptoStore.doTxn( await this._cryptoStore.doTxn(
'readonly', [IndexedDBCryptoStore.STORE_SESSIONS], 'readonly', [IndexedDBCryptoStore.STORE_SESSIONS],
(txn) => { (txn) => {
this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => { this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => {
matches = session.matches_inbound(ciphertext); matches = sessionInfo.session.matches_inbound(ciphertext);
}); });
}, },
); );
@@ -724,11 +751,11 @@ OlmDevice.prototype._saveOutboundGroupSession = function(session) {
*/ */
OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) { OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) {
const pickled = this._outboundGroupSessionStore[sessionId]; const pickled = this._outboundGroupSessionStore[sessionId];
if (pickled === null) { if (pickled === undefined) {
throw new Error("Unknown outbound group session " + sessionId); throw new Error("Unknown outbound group session " + sessionId);
} }
const session = new Olm.OutboundGroupSession(); const session = new global.Olm.OutboundGroupSession();
try { try {
session.unpickle(this._pickleKey, pickled); session.unpickle(this._pickleKey, pickled);
return func(session); return func(session);
@@ -744,7 +771,7 @@ OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) {
* @return {string} sessionId for the outbound session. * @return {string} sessionId for the outbound session.
*/ */
OlmDevice.prototype.createOutboundGroupSession = function() { OlmDevice.prototype.createOutboundGroupSession = function() {
const session = new Olm.OutboundGroupSession(); const session = new global.Olm.OutboundGroupSession();
try { try {
session.create(); session.create();
this._saveOutboundGroupSession(session); this._saveOutboundGroupSession(session);
@@ -816,7 +843,7 @@ OlmDevice.prototype.getOutboundGroupSessionKey = function(sessionId) {
* @return {*} result of func * @return {*} result of func
*/ */
OlmDevice.prototype._unpickleInboundGroupSession = function(sessionData, func) { OlmDevice.prototype._unpickleInboundGroupSession = function(sessionData, func) {
const session = new Olm.InboundGroupSession(); const session = new global.Olm.InboundGroupSession();
try { try {
session.unpickle(this._pickleKey, sessionData.session); session.unpickle(this._pickleKey, sessionData.session);
return func(session); return func(session);
@@ -889,7 +916,7 @@ OlmDevice.prototype.addInboundGroupSession = async function(
roomId, senderKey, sessionId, txn, roomId, senderKey, sessionId, txn,
(existingSession, existingSessionData) => { (existingSession, existingSessionData) => {
if (existingSession) { if (existingSession) {
console.log( logger.log(
"Update for megolm session " + senderKey + "/" + sessionId, "Update for megolm session " + senderKey + "/" + sessionId,
); );
// for now we just ignore updates. TODO: implement something here // for now we just ignore updates. TODO: implement something here
@@ -897,7 +924,7 @@ OlmDevice.prototype.addInboundGroupSession = async function(
} }
// new session. // new session.
const session = new Olm.InboundGroupSession(); const session = new global.Olm.InboundGroupSession();
try { try {
if (exportFormat) { if (exportFormat) {
session.import_session(sessionKey); session.import_session(sessionKey);
@@ -1034,7 +1061,7 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se
} }
if (roomId !== sessionData.room_id) { if (roomId !== sessionData.room_id) {
console.warn( logger.warn(
`requested keys for inbound group session ${senderKey}|` + `requested keys for inbound group session ${senderKey}|` +
`${sessionId}, with incorrect room_id ` + `${sessionId}, with incorrect room_id ` +
`(expected ${sessionData.room_id}, ` + `(expected ${sessionData.room_id}, ` +
@@ -1058,6 +1085,8 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se
* @param {string} roomId room in which the message was received * @param {string} roomId room in which the message was received
* @param {string} senderKey base64-encoded curve25519 key of the sender * @param {string} senderKey base64-encoded curve25519 key of the sender
* @param {string} sessionId session identifier * @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, * @returns {Promise<{chain_index: number, key: string,
* forwarding_curve25519_key_chain: Array<string>, * forwarding_curve25519_key_chain: Array<string>,
@@ -1065,9 +1094,12 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se
* }>} * }>}
* details of the session key. The key is a base64-encoded megolm key in * details of the session key. The key is a base64-encoded megolm key in
* export format. * 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( OlmDevice.prototype.getInboundGroupSessionKey = async function(
roomId, senderKey, sessionId, roomId, senderKey, sessionId, chainIndex,
) { ) {
let result; let result;
await this._cryptoStore.doTxn( await this._cryptoStore.doTxn(
@@ -1078,14 +1110,19 @@ OlmDevice.prototype.getInboundGroupSessionKey = async function(
result = null; result = null;
return; return;
} }
const messageIndex = session.first_known_index();
if (chainIndex === undefined) {
chainIndex = session.first_known_index();
}
const exportedSession = session.export_session(chainIndex);
const claimedKeys = sessionData.keysClaimed || {}; const claimedKeys = sessionData.keysClaimed || {};
const senderEd25519Key = claimedKeys.ed25519 || null; const senderEd25519Key = claimedKeys.ed25519 || null;
result = { result = {
"chain_index": messageIndex, "chain_index": chainIndex,
"key": session.export_session(messageIndex), "key": exportedSession,
"forwarding_curve25519_key_chain": "forwarding_curve25519_key_chain":
sessionData.forwardingCurve25519KeyChain || [], sessionData.forwardingCurve25519KeyChain || [],
"sender_claimed_ed25519_key": senderEd25519Key, "sender_claimed_ed25519_key": senderEd25519Key,
@@ -1119,6 +1156,7 @@ OlmDevice.prototype.exportInboundGroupSession = function(
"session_id": sessionId, "session_id": sessionId,
"session_key": session.export_session(messageIndex), "session_key": session.export_session(messageIndex),
"forwarding_curve25519_key_chain": session.forwardingCurve25519KeyChain || [], "forwarding_curve25519_key_chain": session.forwardingCurve25519KeyChain || [],
"first_known_index": session.first_known_index(),
}; };
}); });
}; };

View File

@@ -16,6 +16,7 @@ limitations under the License.
import Promise from 'bluebird'; import Promise from 'bluebird';
import logger from '../logger';
import utils from '../utils'; import utils from '../utils';
/** /**
@@ -108,7 +109,7 @@ export default class OutgoingRoomKeyRequestManager {
* Called when the client is stopped. Stops any running background processes. * Called when the client is stopped. Stops any running background processes.
*/ */
stop() { stop() {
console.log('stopping OutgoingRoomKeyRequestManager'); logger.log('stopping OutgoingRoomKeyRequestManager');
// stop the timer on the next run // stop the timer on the next run
this._clientRunning = false; this._clientRunning = false;
} }
@@ -173,7 +174,7 @@ export default class OutgoingRoomKeyRequestManager {
// may have seen it, so we still need to send a cancellation // may have seen it, so we still need to send a cancellation
// in that case :/ // in that case :/
console.log( logger.log(
'deleting unnecessary room key request for ' + 'deleting unnecessary room key request for ' +
stringifyRequestBody(requestBody), stringifyRequestBody(requestBody),
); );
@@ -201,7 +202,7 @@ export default class OutgoingRoomKeyRequestManager {
// the request cancelled. There is no point in // the request cancelled. There is no point in
// sending another cancellation since the other tab // sending another cancellation since the other tab
// will do it. // will do it.
console.log( logger.log(
'Tried to cancel room key request for ' + 'Tried to cancel room key request for ' +
stringifyRequestBody(requestBody) + stringifyRequestBody(requestBody) +
' but it was already cancelled in another tab', ' but it was already cancelled in another tab',
@@ -222,7 +223,7 @@ export default class OutgoingRoomKeyRequestManager {
updatedReq, updatedReq,
andResend, andResend,
).catch((e) => { ).catch((e) => {
console.error( logger.error(
"Error sending room key request cancellation;" "Error sending room key request cancellation;"
+ " will retry later.", e, + " will retry later.", e,
); );
@@ -243,6 +244,21 @@ export default class OutgoingRoomKeyRequestManager {
}); });
} }
/**
* Look for room key requests by target device and state
*
* @param {string} userId Target user ID
* @param {string} deviceId Target device ID
*
* @return {Promise} resolves to a list of all the
* {@link module:crypto/store/base~OutgoingRoomKeyRequest}
*/
getOutgoingSentRoomKeyRequest(userId, deviceId) {
return this._cryptoStore.getOutgoingRoomKeyRequestsByTarget(
userId, deviceId, [ROOM_KEY_REQUEST_STATES.SENT],
);
}
// start the background timer to send queued requests, if the timer isn't // start the background timer to send queued requests, if the timer isn't
// already running // already running
_startTimer() { _startTimer() {
@@ -261,7 +277,7 @@ export default class OutgoingRoomKeyRequestManager {
}).catch((e) => { }).catch((e) => {
// this should only happen if there is an indexeddb error, // this should only happen if there is an indexeddb error,
// in which case we're a bit stuffed anyway. // in which case we're a bit stuffed anyway.
console.warn( logger.warn(
`error in OutgoingRoomKeyRequestManager: ${e}`, `error in OutgoingRoomKeyRequestManager: ${e}`,
); );
}).done(); }).done();
@@ -282,7 +298,7 @@ export default class OutgoingRoomKeyRequestManager {
return Promise.resolve(); return Promise.resolve();
} }
console.log("Looking for queued outgoing room key requests"); logger.log("Looking for queued outgoing room key requests");
return this._cryptoStore.getOutgoingRoomKeyRequestByState([ return this._cryptoStore.getOutgoingRoomKeyRequestByState([
ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING,
@@ -290,7 +306,7 @@ export default class OutgoingRoomKeyRequestManager {
ROOM_KEY_REQUEST_STATES.UNSENT, ROOM_KEY_REQUEST_STATES.UNSENT,
]).then((req) => { ]).then((req) => {
if (!req) { if (!req) {
console.log("No more outgoing room key requests"); logger.log("No more outgoing room key requests");
this._sendOutgoingRoomKeyRequestsTimer = null; this._sendOutgoingRoomKeyRequestsTimer = null;
return; return;
} }
@@ -312,7 +328,7 @@ export default class OutgoingRoomKeyRequestManager {
// go around the loop again // go around the loop again
return this._sendOutgoingRoomKeyRequests(); return this._sendOutgoingRoomKeyRequests();
}).catch((e) => { }).catch((e) => {
console.error("Error sending room key request; will retry later.", e); logger.error("Error sending room key request; will retry later.", e);
this._sendOutgoingRoomKeyRequestsTimer = null; this._sendOutgoingRoomKeyRequestsTimer = null;
this._startTimer(); this._startTimer();
}).done(); }).done();
@@ -321,7 +337,7 @@ export default class OutgoingRoomKeyRequestManager {
// given a RoomKeyRequest, send it and update the request record // given a RoomKeyRequest, send it and update the request record
_sendOutgoingRoomKeyRequest(req) { _sendOutgoingRoomKeyRequest(req) {
console.log( logger.log(
`Requesting keys for ${stringifyRequestBody(req.requestBody)}` + `Requesting keys for ${stringifyRequestBody(req.requestBody)}` +
` from ${stringifyRecipientList(req.recipients)}` + ` from ${stringifyRecipientList(req.recipients)}` +
`(id ${req.requestId})`, `(id ${req.requestId})`,
@@ -347,7 +363,7 @@ export default class OutgoingRoomKeyRequestManager {
// Given a RoomKeyRequest, cancel it and delete the request record unless // Given a RoomKeyRequest, cancel it and delete the request record unless
// andResend is set, in which case transition to UNSENT. // andResend is set, in which case transition to UNSENT.
_sendOutgoingRoomKeyRequestCancellation(req, andResend) { _sendOutgoingRoomKeyRequestCancellation(req, andResend) {
console.log( logger.log(
`Sending cancellation for key request for ` + `Sending cancellation for key request for ` +
`${stringifyRequestBody(req.requestBody)} to ` + `${stringifyRequestBody(req.requestBody)} to ` +
`${stringifyRecipientList(req.recipients)} ` + `${stringifyRecipientList(req.recipients)} ` +

View File

@@ -71,6 +71,9 @@ export default class RoomList {
} }
async setRoomEncryption(roomId, roomInfo) { 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; this._roomEncryption[roomId] = roomInfo;
await this._cryptoStore.doTxn( await this._cryptoStore.doTxn(
'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => {

View File

@@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -23,6 +24,7 @@ limitations under the License.
import Promise from 'bluebird'; import Promise from 'bluebird';
const logger = require("../../logger");
const utils = require("../../utils"); const utils = require("../../utils");
const olmlib = require("../olmlib"); const olmlib = require("../olmlib");
const base = require("./base"); const base = require("./base");
@@ -64,7 +66,7 @@ OutboundSessionInfo.prototype.needsRotation = function(
if (this.useCount >= rotationPeriodMsgs || if (this.useCount >= rotationPeriodMsgs ||
sessionLifetime >= rotationPeriodMs sessionLifetime >= rotationPeriodMs
) { ) {
console.log( logger.log(
"Rotating megolm session after " + this.useCount + "Rotating megolm session after " + this.useCount +
" messages, " + sessionLifetime + "ms", " messages, " + sessionLifetime + "ms",
); );
@@ -102,7 +104,7 @@ OutboundSessionInfo.prototype.sharedWithTooManyDevices = function(
} }
if (!devicesInRoom.hasOwnProperty(userId)) { if (!devicesInRoom.hasOwnProperty(userId)) {
console.log("Starting new session because we shared with " + userId); logger.log("Starting new session because we shared with " + userId);
return true; return true;
} }
@@ -112,7 +114,7 @@ OutboundSessionInfo.prototype.sharedWithTooManyDevices = function(
} }
if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { if (!devicesInRoom[userId].hasOwnProperty(deviceId)) {
console.log( logger.log(
"Starting new session because we shared with " + "Starting new session because we shared with " +
userId + ":" + deviceId, userId + ":" + deviceId,
); );
@@ -142,6 +144,11 @@ function MegolmEncryption(params) {
// room). // room).
this._setupPromise = Promise.resolve(); 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 // default rotation periods
this._sessionRotationPeriodMsgs = 100; this._sessionRotationPeriodMsgs = 100;
this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000; this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000;
@@ -181,7 +188,7 @@ MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) {
if (session && session.needsRotation(self._sessionRotationPeriodMsgs, if (session && session.needsRotation(self._sessionRotationPeriodMsgs,
self._sessionRotationPeriodMs) self._sessionRotationPeriodMs)
) { ) {
console.log("Starting new megolm session because we need to rotate."); logger.log("Starting new megolm session because we need to rotate.");
session = null; session = null;
} }
@@ -191,8 +198,9 @@ MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) {
} }
if (!session) { if (!session) {
console.log(`Starting new megolm session for room ${self._roomId}`); logger.log(`Starting new megolm session for room ${self._roomId}`);
session = await self._prepareNewSession(); session = await self._prepareNewSession();
self._outboundSessions[session.sessionId] = session;
} }
// now check if we need to share with any devices // now check if we need to share with any devices
@@ -262,6 +270,18 @@ MegolmEncryption.prototype._prepareNewSession = async function() {
key.key, {ed25519: this._olmDevice.deviceEd25519Key}, key.key, {ed25519: this._olmDevice.deviceEd25519Key},
); );
if (this._crypto.backupInfo) {
// don't wait for it to complete
this._crypto.backupGroupSession(
this._roomId, this._olmDevice.deviceCurve25519Key, [],
sessionId, key.key,
).catch((e) => {
// This throws if the upload failed, but this is fine
// since it will have written it to the db and will retry.
console.log("Failed to back up group session", e);
});
}
return new OutboundSessionInfo(sessionId); return new OutboundSessionInfo(sessionId);
}; };
@@ -318,7 +338,7 @@ MegolmEncryption.prototype._splitUserDeviceMap = function(
continue; continue;
} }
console.log( logger.log(
"share keys with device " + userId + ":" + deviceId, "share keys with device " + userId + ":" + deviceId,
); );
@@ -407,8 +427,98 @@ MegolmEncryption.prototype._encryptAndSendKeysToDevices = function(
}; };
/** /**
* @private * Re-shares a megolm session key with devices if the key has already been
* sent to them.
* *
* @param {string} senderKey The key of the originating device for the session
* @param {string} sessionId ID of the outbound session to share
* @param {string} userId ID of the user who owns the target device
* @param {module:crypto/deviceinfo} device The target device
*/
MegolmEncryption.prototype.reshareKeyWithDevice = async function(
senderKey, sessionId, userId, device,
) {
const obSessionInfo = this._outboundSessions[sessionId];
if (!obSessionInfo) {
logger.debug("Session ID " + sessionId + " not found: not re-sharing keys");
return;
}
// The chain index of the key we previously sent this device
if (obSessionInfo.sharedWithDevices[userId] === undefined) {
logger.debug("Session ID " + sessionId + " never shared with user " + userId);
return;
}
const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId];
if (sentChainIndex === undefined) {
logger.debug(
"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 outbound session key found for " + sessionId + ": not re-sharing keys",
);
return;
}
await olmlib.ensureOlmSessionsForDevices(
this._olmDevice, this._baseApis, {
[userId]: {
[device.deviceId]: device,
},
},
);
const payload = {
type: "m.forwarded_room_key",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: this._roomId,
session_id: sessionId,
session_key: key.key,
chain_index: key.chain_index,
sender_key: senderKey,
sender_claimed_ed25519_key: key.sender_claimed_ed25519_key,
forwarding_curve25519_key_chain: key.forwarding_curve25519_key_chain,
},
};
const encryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this._olmDevice.deviceCurve25519Key,
ciphertext: {},
};
await olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this._userId,
this._deviceId,
this._olmDevice,
userId,
device,
payload,
),
await this._baseApis.sendToDevice("m.room.encrypted", {
[userId]: {
[device.deviceId]: encryptedContent,
},
});
logger.debug(
`Re-shared key for session ${sessionId} with ${userId}:${device.deviceId}`,
);
};
/**
* @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session
* *
* @param {object<string, module:crypto/deviceinfo[]>} devicesByUser * @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
@@ -440,10 +550,10 @@ MegolmEncryption.prototype._shareKeyWithDevices = async function(session, device
await this._encryptAndSendKeysToDevices( await this._encryptAndSendKeysToDevices(
session, key.chain_index, userDeviceMaps[i], payload, session, key.chain_index, userDeviceMaps[i], payload,
); );
console.log(`Completed megolm keyshare in ${this._roomId} ` logger.log(`Completed megolm keyshare in ${this._roomId} `
+ `(slice ${i + 1}/${userDeviceMaps.length})`); + `(slice ${i + 1}/${userDeviceMaps.length})`);
} catch (e) { } catch (e) {
console.log(`megolm keyshare in ${this._roomId} ` logger.log(`megolm keyshare in ${this._roomId} `
+ `(slice ${i + 1}/${userDeviceMaps.length}) failed`); + `(slice ${i + 1}/${userDeviceMaps.length}) failed`);
throw e; throw e;
@@ -462,7 +572,7 @@ MegolmEncryption.prototype._shareKeyWithDevices = async function(session, device
*/ */
MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) { MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) {
const self = this; const self = this;
console.log(`Starting to encrypt event for ${this._roomId}`); logger.log(`Starting to encrypt event for ${this._roomId}`);
return this._getDevicesInRoom(room).then(function(devicesInRoom) { return this._getDevicesInRoom(room).then(function(devicesInRoom) {
// check if any of these devices are not yet known to the user. // check if any of these devices are not yet known to the user.
@@ -488,6 +598,8 @@ MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) {
session_id: session.sessionId, session_id: session.sessionId,
// Include our device ID so that recipients can send us a // Include our device ID so that recipients can send us a
// m.new_device message if they don't have our session key. // m.new_device message if they don't have our session key.
// XXX: Do we still need this now that m.new_device messages
// no longer exist since #483?
device_id: self._deviceId, device_id: self._deviceId,
}; };
@@ -496,6 +608,16 @@ MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) {
}); });
}; };
/**
* 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 * 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 * unknown to the user. If so, warn the user, and mark them as known to
@@ -535,8 +657,9 @@ MegolmEncryption.prototype._checkForUnknownDevices = function(devicesInRoom) {
* @return {module:client.Promise} Promise which resolves to a map * @return {module:client.Promise} Promise which resolves to a map
* from userId to deviceId to deviceInfo * from userId to deviceId to deviceInfo
*/ */
MegolmEncryption.prototype._getDevicesInRoom = function(room) { MegolmEncryption.prototype._getDevicesInRoom = async function(room) {
const roomMembers = utils.map(room.getEncryptionTargetMembers(), function(u) { const members = await room.getEncryptionTargetMembers();
const roomMembers = utils.map(members, function(u) {
return u.userId; return u.userId;
}); });
@@ -549,13 +672,10 @@ MegolmEncryption.prototype._getDevicesInRoom = function(room) {
// We are happy to use a cached version here: we assume that if we already // 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 // 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 // with them, which means that they will have announced any new devices via
// an m.new_device. // device_lists in their /sync response. This cache should then be maintained
// // using all the device_lists changes and left fields.
// XXX: what if the cache is stale, and the user left the room we had in // See https://github.com/vector-im/riot-web/issues/2305 for details.
// common and then added new devices before joining this one? --Matthew const devices = await this._crypto.downloadKeys(roomMembers, false);
//
// yup, see https://github.com/vector-im/riot-web/issues/2305 --richvdh
return this._crypto.downloadKeys(roomMembers, false).then((devices) => {
// remove any blocked devices // remove any blocked devices
for (const userId in devices) { for (const userId in devices) {
if (!devices.hasOwnProperty(userId)) { if (!devices.hasOwnProperty(userId)) {
@@ -577,7 +697,6 @@ MegolmEncryption.prototype._getDevicesInRoom = function(room) {
} }
return devices; return devices;
});
}; };
/** /**
@@ -772,12 +891,12 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
!sessionId || !sessionId ||
!content.session_key !content.session_key
) { ) {
console.error("key event is missing fields"); logger.error("key event is missing fields");
return; return;
} }
if (!senderKey) { if (!senderKey) {
console.error("key event has no sender key (not encrypted?)"); logger.error("key event has no sender key (not encrypted?)");
return; return;
} }
@@ -794,13 +913,13 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
senderKey = content.sender_key; senderKey = content.sender_key;
if (!senderKey) { if (!senderKey) {
console.error("forwarded_room_key event is missing sender_key field"); logger.error("forwarded_room_key event is missing sender_key field");
return; return;
} }
const ed25519Key = content.sender_claimed_ed25519_key; const ed25519Key = content.sender_claimed_ed25519_key;
if (!ed25519Key) { if (!ed25519Key) {
console.error( logger.error(
`forwarded_room_key_event is missing sender_claimed_ed25519_key field`, `forwarded_room_key_event is missing sender_claimed_ed25519_key field`,
); );
return; return;
@@ -813,8 +932,8 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
keysClaimed = event.getKeysClaimed(); keysClaimed = event.getKeysClaimed();
} }
console.log(`Adding key for megolm session ${senderKey}|${sessionId}`); logger.log(`Adding key for megolm session ${senderKey}|${sessionId}`);
this._olmDevice.addInboundGroupSession( return this._olmDevice.addInboundGroupSession(
content.room_id, senderKey, forwardingKeyChain, sessionId, content.room_id, senderKey, forwardingKeyChain, sessionId,
content.session_key, keysClaimed, content.session_key, keysClaimed,
exportFormat, exportFormat,
@@ -829,8 +948,21 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
// have another go at decrypting events sent with this session. // have another go at decrypting events sent with this session.
this._retryDecryption(senderKey, sessionId); this._retryDecryption(senderKey, sessionId);
}).then(() => {
if (this._crypto.backupInfo) {
// don't wait for the keys to be backed up for the server
this._crypto.backupGroupSession(
content.room_id, senderKey, forwardingKeyChain,
content.session_id, content.session_key, keysClaimed,
exportFormat,
).catch((e) => {
// This throws if the upload failed, but this is fine
// since it will have written it to the db and will retry.
console.log("Failed to back up group session", e);
});
}
}).catch((e) => { }).catch((e) => {
console.error(`Error handling m.room_key_event: ${e}`); logger.error(`Error handling m.room_key_event: ${e}`);
}); });
}; };
@@ -872,7 +1004,7 @@ MegolmDecryption.prototype.shareKeysWithDevice = function(keyRequest) {
return null; return null;
} }
console.log( logger.log(
"sharing keys for session " + body.sender_key + "|" "sharing keys for session " + body.sender_key + "|"
+ body.session_id + " with device " + body.session_id + " with device "
+ userId + ":" + deviceId, + userId + ":" + deviceId,
@@ -946,6 +1078,22 @@ MegolmDecryption.prototype.importRoomKey = function(session) {
session.sender_claimed_keys, session.sender_claimed_keys,
true, true,
).then(() => { ).then(() => {
if (this._crypto.backupInfo) {
// don't wait for it to complete
this._crypto.backupGroupSession(
session.room_id,
session.sender_key,
session.forwarding_curve25519_key_chain,
session.session_id,
session.session_key,
session.sender_claimed_keys,
true,
).catch((e) => {
// This throws if the upload failed, but this is fine
// since it will have written it to the db and will retry.
console.log("Failed to back up group session", e);
});
}
// have another go at decrypting events sent with this session. // have another go at decrypting events sent with this session.
this._retryDecryption(session.sender_key, session.session_id); this._retryDecryption(session.sender_key, session.session_id);
}); });

View File

@@ -22,6 +22,7 @@ limitations under the License.
*/ */
import Promise from 'bluebird'; import Promise from 'bluebird';
const logger = require("../../logger");
const utils = require("../../utils"); const utils = require("../../utils");
const olmlib = require("../olmlib"); const olmlib = require("../olmlib");
const DeviceInfo = require("../deviceinfo"); const DeviceInfo = require("../deviceinfo");
@@ -83,18 +84,21 @@ OlmEncryption.prototype._ensureSession = function(roomMembers) {
* *
* @return {module:client.Promise} Promise which resolves to the new event body * @return {module:client.Promise} Promise which resolves to the new event body
*/ */
OlmEncryption.prototype.encryptMessage = function(room, eventType, content) { OlmEncryption.prototype.encryptMessage = async function(room, eventType, content) {
// pick the list of recipients based on the membership list. // pick the list of recipients based on the membership list.
// //
// TODO: there is a race condition here! What if a new user turns up // TODO: there is a race condition here! What if a new user turns up
// just as you are sending a secret message? // just as you are sending a secret message?
const users = utils.map(room.getEncryptionTargetMembers(), function(u) { const members = await room.getEncryptionTargetMembers();
const users = utils.map(members, function(u) {
return u.userId; return u.userId;
}); });
const self = this; const self = this;
return this._ensureSession(users).then(function() { await this._ensureSession(users);
const payloadFields = { const payloadFields = {
room_id: room.roomId, room_id: room.roomId,
type: eventType, type: eventType,
@@ -135,8 +139,7 @@ OlmEncryption.prototype.encryptMessage = function(room, eventType, content) {
} }
} }
return Promise.all(promises).return(encryptedContent); return await Promise.all(promises).return(encryptedContent);
});
}; };
/** /**
@@ -271,7 +274,7 @@ OlmDecryption.prototype._decryptMessage = async function(
const payload = await this._olmDevice.decryptMessage( const payload = await this._olmDevice.decryptMessage(
theirDeviceIdentityKey, sessionId, message.type, message.body, theirDeviceIdentityKey, sessionId, message.type, message.body,
); );
console.log( logger.log(
"Decrypted Olm message from " + theirDeviceIdentityKey + "Decrypted Olm message from " + theirDeviceIdentityKey +
" with session " + sessionId, " with session " + sessionId,
); );
@@ -326,7 +329,7 @@ OlmDecryption.prototype._decryptMessage = async function(
); );
} }
console.log( logger.log(
"created new inbound Olm session ID " + "created new inbound Olm session ID " +
res.session_id + " with " + theirDeviceIdentityKey, res.session_id + " with " + theirDeviceIdentityKey,
); );

View File

@@ -0,0 +1,81 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { randomString } from '../randomstring';
const DEFAULT_ITERATIONS = 500000;
export async function keyForExistingBackup(backupData, password) {
if (!global.Olm) {
throw new Error("Olm is not available");
}
const authData = backupData.auth_data;
if (!authData.private_key_salt || !authData.private_key_iterations) {
throw new Error(
"Salt and/or iterations not found: " +
"this backup cannot be restored with a passphrase",
);
}
return await deriveKey(
password, backupData.auth_data.private_key_salt,
backupData.auth_data.private_key_iterations,
);
}
export async function keyForNewBackup(password) {
if (!global.Olm) {
throw new Error("Olm is not available");
}
const salt = randomString(32);
const key = await deriveKey(password, salt, DEFAULT_ITERATIONS);
return { key, salt, iterations: DEFAULT_ITERATIONS };
}
async function deriveKey(password, salt, iterations) {
const subtleCrypto = global.crypto.subtle;
const TextEncoder = global.TextEncoder;
if (!subtleCrypto || !TextEncoder) {
// TODO: Implement this for node
throw new Error("Password-based backup is not avaiable on this platform");
}
const key = await subtleCrypto.importKey(
'raw',
new TextEncoder().encode(password),
{name: 'PBKDF2'},
false,
['deriveBits'],
);
const keybits = await subtleCrypto.deriveBits(
{
name: 'PBKDF2',
salt: new TextEncoder().encode(salt),
iterations: iterations,
hash: 'SHA-512',
},
key,
global.Olm.PRIVATE_KEY_LENGTH * 8,
);
return new Uint8Array(keybits);
}

View File

@@ -25,6 +25,7 @@ const anotherjson = require('another-json');
import Promise from 'bluebird'; import Promise from 'bluebird';
import {EventEmitter} from 'events'; import {EventEmitter} from 'events';
const logger = require("../logger");
const utils = require("../utils"); const utils = require("../utils");
const OlmDevice = require("./OlmDevice"); const OlmDevice = require("./OlmDevice");
const olmlib = require("./olmlib"); const olmlib = require("./olmlib");
@@ -36,6 +37,12 @@ const DeviceList = require('./DeviceList').default;
import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager'; import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager';
import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
export function isCryptoAvailable() {
return Boolean(global.Olm);
}
const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000;
/** /**
* Cryptography bits * Cryptography bits
* *
@@ -62,7 +69,7 @@ import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
* *
* @param {RoomList} roomList An initialised RoomList object * @param {RoomList} roomList An initialised RoomList object
*/ */
function Crypto(baseApis, sessionStore, userId, deviceId, export default function Crypto(baseApis, sessionStore, userId, deviceId,
clientStore, cryptoStore, roomList) { clientStore, cryptoStore, roomList) {
this._baseApis = baseApis; this._baseApis = baseApis;
this._sessionStore = sessionStore; this._sessionStore = sessionStore;
@@ -72,6 +79,14 @@ function Crypto(baseApis, sessionStore, userId, deviceId,
this._cryptoStore = cryptoStore; this._cryptoStore = cryptoStore;
this._roomList = roomList; this._roomList = roomList;
// 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(sessionStore, cryptoStore); this._olmDevice = new OlmDevice(sessionStore, cryptoStore);
this._deviceList = new DeviceList( this._deviceList = new DeviceList(
baseApis, cryptoStore, sessionStore, this._olmDevice, baseApis, cryptoStore, sessionStore, this._olmDevice,
@@ -106,6 +121,24 @@ function Crypto(baseApis, sessionStore, userId, deviceId,
this._receivedRoomKeyRequestCancellations = []; this._receivedRoomKeyRequestCancellations = [];
// true if we are currently processing received room key requests // true if we are currently processing received room key requests
this._processingRoomKeyRequests = false; 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 = {};
} }
utils.inherits(Crypto, EventEmitter); utils.inherits(Crypto, EventEmitter);
@@ -115,6 +148,8 @@ utils.inherits(Crypto, EventEmitter);
* Returns a promise which resolves once the crypto module is ready for use. * Returns a promise which resolves once the crypto module is ready for use.
*/ */
Crypto.prototype.init = async function() { Crypto.prototype.init = async function() {
await global.Olm.init();
const sessionStoreHasAccount = Boolean(this._sessionStore.getEndToEndAccount()); const sessionStoreHasAccount = Boolean(this._sessionStore.getEndToEndAccount());
let cryptoStoreHasAccount; let cryptoStoreHasAccount;
await this._cryptoStore.doTxn( await this._cryptoStore.doTxn(
@@ -165,6 +200,126 @@ Crypto.prototype.init = async function() {
); );
this._deviceList.saveIfDirty(); this._deviceList.saveIfDirty();
} }
this._checkAndStartKeyBackup();
};
/**
* Check the server for an active key backup and
* if one is present and has a valid signature from
* one of the user's verified devices, start backing up
* to it.
*/
Crypto.prototype._checkAndStartKeyBackup = async function() {
console.log("Checking key backup status...");
if (this._baseApis.isGuest()) {
console.log("Skipping key backup check since user is guest");
this._checkedForBackup = true;
return;
}
let backupInfo;
try {
backupInfo = await this._baseApis.getKeyBackupVersion();
} catch (e) {
console.log("Error checking for active key backup", e);
if (e.httpStatus / 100 === 4) {
// well that's told us. we won't try again.
this._checkedForBackup = true;
}
return;
}
this._checkedForBackup = true;
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
if (trustInfo.usable && !this.backupInfo) {
console.log("Found usable key backup: enabling key backups");
this._baseApis.enableKeyBackup(backupInfo);
} else if (!trustInfo.usable && this.backupInfo) {
console.log("No usable key backup: disabling key backup");
this._baseApis.disableKeyBackup();
} else if (!trustInfo.usable && !this.backupInfo) {
console.log("No usable key backup: not enabling key backup");
}
};
/**
* Forces a re-check of the key backup and enables/disables it
* as appropriate
*
* @param {object} backupInfo Backup info from /room_keys/version endpoint
*/
Crypto.prototype.checkKeyBackup = async function(backupInfo) {
this._checkedForBackup = false;
await 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],
* device: [DeviceInfo],
* ]
* }
*/
Crypto.prototype.isKeyBackupTrusted = async function(backupInfo) {
const ret = {
usable: false,
sigs: [],
};
if (
!backupInfo ||
!backupInfo.algorithm ||
!backupInfo.auth_data ||
!backupInfo.auth_data.public_key ||
!backupInfo.auth_data.signatures
) {
console.log("Key backup is absent or missing required data");
return ret;
}
const mySigs = backupInfo.auth_data.signatures[this._userId];
if (!mySigs || mySigs.length === 0) {
console.log("Ignoring key backup because it lacks any signatures from this user");
return ret;
}
for (const keyId of Object.keys(mySigs)) {
const device = this._deviceList.getStoredDevice(
this._userId, keyId.split(':')[1], // XXX: is this how we're supposed to get the device ID?
);
if (!device) {
console.log("Ignoring signature from unknown key " + keyId);
continue;
}
const sigInfo = { device };
try {
await olmlib.verifySignature(
this._olmDevice,
backupInfo.auth_data,
this._userId,
device.deviceId,
device.getFingerprint(),
);
sigInfo.valid = true;
} catch (e) {
console.log("Bad signature from device " + device.deviceId, e);
sigInfo.valid = false;
}
ret.sigs.push(sigInfo);
}
ret.usable = ret.sigs.some((s) => s.valid && s.device.isVerified());
return ret;
};
/**
*/
Crypto.prototype.enableLazyLoading = function() {
this._lazyLoadMembers = true;
}; };
/** /**
@@ -181,7 +336,7 @@ Crypto.prototype.registerEventHandlers = function(eventEmitter) {
try { try {
crypto._onRoomMembership(event, member, oldMembership); crypto._onRoomMembership(event, member, oldMembership);
} catch (e) { } catch (e) {
console.error("Error handling membership change:", e); logger.error("Error handling membership change:", e);
} }
}); });
@@ -199,6 +354,7 @@ Crypto.prototype.start = function() {
/** Stop background processes related to crypto */ /** Stop background processes related to crypto */
Crypto.prototype.stop = function() { Crypto.prototype.stop = function() {
this._outgoingRoomKeyRequestManager.stop(); this._outgoingRoomKeyRequestManager.stop();
this._deviceList.stop();
}; };
/** /**
@@ -366,7 +522,7 @@ function _maybeUploadOneTimeKeys(crypto) {
// create any more keys. // create any more keys.
return uploadLoop(keyCount); return uploadLoop(keyCount);
}).catch((e) => { }).catch((e) => {
console.error("Error uploading one-time keys", e.stack || e); logger.error("Error uploading one-time keys", e.stack || e);
}).finally(() => { }).finally(() => {
// reset _oneTimeKeyCount to prevent start uploading based on old data. // reset _oneTimeKeyCount to prevent start uploading based on old data.
// it will be set again on the next /sync-response // it will be set again on the next /sync-response
@@ -573,7 +729,7 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) {
// identity key of the device which set up the Megolm session. // identity key of the device which set up the Megolm session.
const device = this._deviceList.getDeviceByIdentityKey( const device = this._deviceList.getDeviceByIdentityKey(
event.getSender(), algorithm, senderKey, algorithm, senderKey,
); );
if (device === null) { if (device === null) {
@@ -591,13 +747,13 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) {
const claimedKey = event.getClaimedEd25519Key(); const claimedKey = event.getClaimedEd25519Key();
if (!claimedKey) { if (!claimedKey) {
console.warn("Event " + event.getId() + " claims no ed25519 key: " + logger.warn("Event " + event.getId() + " claims no ed25519 key: " +
"cannot verify sending device"); "cannot verify sending device");
return null; return null;
} }
if (claimedKey !== device.getFingerprint()) { if (claimedKey !== device.getFingerprint()) {
console.warn( logger.warn(
"Event " + event.getId() + " claims ed25519 key " + claimedKey + "Event " + event.getId() + " claims ed25519 key " + claimedKey +
"but sender device has key " + device.getFingerprint()); "but sender device has key " + device.getFingerprint());
return null; return null;
@@ -606,6 +762,23 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) {
return device; return device;
}; };
/**
* Forces the current outbound group session to be discarded such
* that another one will be created next time an event is sent.
*
* @param {string} roomId The ID of the room to discard the session for
*
* This should not normally be necessary.
*/
Crypto.prototype.forceDiscardSession = function(roomId) {
const alg = this._roomEncryptors[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 sessionstore). * Configure a room to use encryption (ie, save a flag in the sessionstore).
* *
@@ -614,25 +787,49 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) {
* @param {object} config The encryption config for the room. * @param {object} config The encryption config for the room.
* *
* @param {boolean=} inhibitDeviceQuery true to suppress device list query for * @param {boolean=} inhibitDeviceQuery true to suppress device list query for
* users in the room (for now) * 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) { Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDeviceQuery) {
// if we already have encryption in this room, we should ignore this event // if state is being replayed from storage, we might already have a configuration
// (for now at least. maybe we should alert the user somehow?) // 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); const existingConfig = this._roomList.getRoomEncryption(roomId);
if (existingConfig && JSON.stringify(existingConfig) != JSON.stringify(config)) { if (existingConfig) {
console.error("Ignoring m.room.encryption event which requests " + if (JSON.stringify(existingConfig) != JSON.stringify(config)) {
logger.error("Ignoring m.room.encryption event which requests " +
"a change of config in " + roomId); "a change of config in " + roomId);
return; 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]; const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm];
if (!AlgClass) { if (!AlgClass) {
throw new Error("Unable to encrypt with " + config.algorithm); throw new Error("Unable to encrypt with " + config.algorithm);
} }
await this._roomList.setRoomEncryption(roomId, config);
const alg = new AlgClass({ const alg = new AlgClass({
userId: this._userId, userId: this._userId,
deviceId: this._deviceId, deviceId: this._deviceId,
@@ -644,24 +841,59 @@ Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDevic
}); });
this._roomEncryptors[roomId] = alg; this._roomEncryptors[roomId] = alg;
// make sure we are tracking the device lists for all users in this room. if (storeConfigPromise) {
console.log("Enabling encryption in " + roomId + "; " + await storeConfigPromise;
"starting to track device lists for all users therein");
const room = this._clientStore.getRoom(roomId);
if (!room) {
throw new Error(`Unable to enable encryption in unknown room ${roomId}`);
} }
const members = room.getEncryptionTargetMembers(); if (!this._lazyLoadMembers) {
members.forEach((m) => { logger.log("Enabling encryption in " + roomId + "; " +
this._deviceList.startTrackingDeviceList(m.userId); "starting to track device lists for all users therein");
});
if (!inhibitDeviceQuery) { await this.trackRoomDevices(roomId);
// TODO: this flag is only not used from MatrixClient::setRoomEncryption
// which is never used (inside riot at least)
// but didn't want to remove it as it technically would
// be a breaking change.
if(!this.inhibitDeviceQuery) {
this._deviceList.refreshOutdatedDeviceLists(); 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;
}
return promise;
};
/** /**
* @typedef {Object} module:crypto~OlmSessionResult * @typedef {Object} module:crypto~OlmSessionResult
* @property {module:crypto/deviceinfo} device device info * @property {module:crypto/deviceinfo} device device info
@@ -724,6 +956,7 @@ Crypto.prototype.exportRoomKeys = async function() {
const sess = this._olmDevice.exportInboundGroupSession( const sess = this._olmDevice.exportInboundGroupSession(
s.senderKey, s.sessionId, s.sessionData, s.senderKey, s.sessionId, s.sessionData,
); );
delete sess.first_known_index;
sess.algorithm = olmlib.MEGOLM_ALGORITHM; sess.algorithm = olmlib.MEGOLM_ALGORITHM;
exportedSessions.push(sess); exportedSessions.push(sess);
}); });
@@ -743,7 +976,7 @@ Crypto.prototype.importRoomKeys = function(keys) {
return Promise.map( return Promise.map(
keys, (key) => { keys, (key) => {
if (!key.room_id || !key.algorithm) { if (!key.room_id || !key.algorithm) {
console.warn("ignoring room key entry with missing fields", key); logger.warn("ignoring room key entry with missing fields", key);
return null; return null;
} }
@@ -753,6 +986,133 @@ Crypto.prototype.importRoomKeys = function(keys) {
); );
}; };
Crypto.prototype._maybeSendKeyBackup = async function(delay, retry) {
if (retry === undefined) retry = true;
if (!this._sendingBackups) {
this._sendingBackups = true;
try {
if (delay === undefined) {
// by default, wait between 0 and 10 seconds, to avoid backup
// requests from different clients hitting the server all at
// the same time when a new key is sent
delay = Math.random() * 10000;
}
await Promise.delay(delay);
let numFailures = 0; // number of consecutive failures
while (1) {
if (!this.backupKey) {
return;
}
// FIXME: figure out what limit is reasonable
const sessions = await this._cryptoStore.getSessionsNeedingBackup(10);
if (!sessions.length) {
return;
}
const data = {};
for (const session of sessions) {
const roomId = session.sessionData.room_id;
if (data[roomId] === undefined) {
data[roomId] = {sessions: {}};
}
const sessionData = await this._olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = olmlib.MEGOLM_ALGORITHM;
delete sessionData.session_id;
delete sessionData.room_id;
const firstKnownIndex = sessionData.first_known_index;
delete sessionData.first_known_index;
const encrypted = this.backupKey.encrypt(JSON.stringify(sessionData));
const forwardedCount =
(sessionData.forwarding_curve25519_key_chain || []).length;
const device = this._deviceList.getDeviceByIdentityKey(
olmlib.MEGOLM_ALGORITHM, session.senderKey,
);
data[roomId]['sessions'][session.sessionId] = {
first_message_index: firstKnownIndex,
forwarded_count: forwardedCount,
is_verified: !!(device && device.isVerified()),
session_data: encrypted,
};
}
try {
await this._baseApis.sendKeyBackup(
undefined, undefined, this.backupInfo.version,
{rooms: data},
);
numFailures = 0;
await this._cryptoStore.unmarkSessionsNeedingBackup(sessions);
} catch (err) {
numFailures++;
console.log("send failed", err);
if (err.httpStatus === 400
|| err.httpStatus === 403
|| err.httpStatus === 401
|| !retry) {
// retrying probably won't help much, so we should give up
// FIXME: disable backups?
throw err;
}
}
if (numFailures) {
// exponential backoff if we have failures
await new Promise((resolve, reject) => {
setTimeout(
resolve,
1000 * Math.pow(2, Math.min(numFailures - 1, 4)),
);
});
}
}
} finally {
this._sendingBackups = false;
}
}
};
Crypto.prototype.backupGroupSession = async function(
roomId, senderKey, forwardingCurve25519KeyChain,
sessionId, sessionKey, keysClaimed,
exportFormat,
) {
if (!this.backupInfo) {
throw new Error("Key backups are not enabled");
}
await this._cryptoStore.markSessionsNeedingBackup([{
senderKey: senderKey,
sessionId: sessionId,
}]);
await this._maybeSendKeyBackup();
};
Crypto.prototype.backupAllGroupSessions = async function(version) {
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);
}
});
},
);
await this._maybeSendKeyBackup(0, false);
};
/* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307
/** /**
* Encrypt an event according to the configuration of the room. * Encrypt an event according to the configuration of the room.
* *
@@ -763,7 +1123,8 @@ Crypto.prototype.importRoomKeys = function(keys) {
* @return {module:client.Promise?} Promise which resolves when the event has been * @return {module:client.Promise?} Promise which resolves when the event has been
* encrypted, or null if nothing was needed * encrypted, or null if nothing was needed
*/ */
Crypto.prototype.encryptEvent = function(event, room) { /* eslint-enable valid-jsdoc */
Crypto.prototype.encryptEvent = async function(event, room) {
if (!room) { if (!room) {
throw new Error("Cannot send encrypted messages in unknown rooms"); throw new Error("Cannot send encrypted messages in unknown rooms");
} }
@@ -781,6 +1142,12 @@ Crypto.prototype.encryptEvent = function(event, room) {
); );
} }
if (!this._roomDeviceTrackingState[roomId]) {
this.trackRoomDevices(roomId);
}
// wait for all the room devices to be loaded
await this._roomDeviceTrackingState[roomId];
let content = event.getContent(); let content = event.getContent();
// If event has an m.relates_to then we need // If event has an m.relates_to then we need
// to put this on the wrapping event instead // to put this on the wrapping event instead
@@ -791,9 +1158,9 @@ Crypto.prototype.encryptEvent = function(event, room) {
delete content['m.relates_to']; delete content['m.relates_to'];
} }
return alg.encryptMessage( const encryptedContent = await alg.encryptMessage(
room, event.getType(), content, room, event.getType(), content);
).then((encryptedContent) => {
if (mRelatesTo) { if (mRelatesTo) {
encryptedContent['m.relates_to'] = mRelatesTo; encryptedContent['m.relates_to'] = mRelatesTo;
} }
@@ -804,7 +1171,6 @@ Crypto.prototype.encryptEvent = function(event, room) {
this._olmDevice.deviceCurve25519Key, this._olmDevice.deviceCurve25519Key,
this._olmDevice.deviceEd25519Key, this._olmDevice.deviceEd25519Key,
); );
});
}; };
/** /**
@@ -852,7 +1218,7 @@ Crypto.prototype.handleDeviceListChanges = async function(syncData, syncDeviceLi
// If we didn't make this assumption, we'd have to use the /keys/changes API // 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' // 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. // sync token used here to make sure we didn't miss any.
this._evalDeviceListChanges(syncDeviceLists); await this._evalDeviceListChanges(syncDeviceLists);
}; };
/** /**
@@ -866,7 +1232,7 @@ Crypto.prototype.requestRoomKey = function(requestBody, recipients) {
requestBody, recipients, requestBody, recipients,
).catch((e) => { ).catch((e) => {
// this normally means we couldn't talk to the store // this normally means we couldn't talk to the store
console.error( logger.error(
'Error requesting key for event', e, 'Error requesting key for event', e,
); );
}).done(); }).done();
@@ -883,7 +1249,7 @@ Crypto.prototype.requestRoomKey = function(requestBody, recipients) {
Crypto.prototype.cancelRoomKeyRequest = function(requestBody, andResend) { Crypto.prototype.cancelRoomKeyRequest = function(requestBody, andResend) {
this._outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody, andResend) this._outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody, andResend)
.catch((e) => { .catch((e) => {
console.warn("Error clearing pending room key requests", e); logger.warn("Error clearing pending room key requests", e);
}).done(); }).done();
}; };
@@ -901,7 +1267,7 @@ Crypto.prototype.onCryptoEvent = async function(event) {
// finished processing the sync, in onSyncCompleted. // finished processing the sync, in onSyncCompleted.
await this.setRoomEncryption(roomId, content, true); await this.setRoomEncryption(roomId, content, true);
} catch (e) { } catch (e) {
console.error("Error configuring encryption in room " + roomId + logger.error("Error configuring encryption in room " + roomId +
":", e); ":", e);
} }
}; };
@@ -917,8 +1283,9 @@ Crypto.prototype.onSyncWillProcess = async function(syncData) {
// scratch, so mark everything as untracked. onCryptoEvent will // scratch, so mark everything as untracked. onCryptoEvent will
// be called for all e2e rooms during the processing of the sync, // 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. // at which point we'll start tracking all the users of that room.
console.log("Initial sync performed - resetting device tracking state"); logger.log("Initial sync performed - resetting device tracking state");
this._deviceList.stopTrackingAllDeviceLists(); this._deviceList.stopTrackingAllDeviceLists();
this._roomDeviceTrackingState = {};
} }
}; };
@@ -964,11 +1331,12 @@ Crypto.prototype._evalDeviceListChanges = async function(deviceLists) {
}); });
} }
if (deviceLists.left && Array.isArray(deviceLists.left)) { if (deviceLists.left && Array.isArray(deviceLists.left) &&
deviceLists.left.length) {
// Check we really don't share any rooms with these users // Check we really don't share any rooms with these users
// any more: the server isn't required to give us the // any more: the server isn't required to give us the
// exact correct set. // exact correct set.
const e2eUserIds = new Set(this._getE2eUsers()); const e2eUserIds = new Set(await this._getTrackedE2eUsers());
deviceLists.left.forEach((u) => { deviceLists.left.forEach((u) => {
if (!e2eUserIds.has(u)) { if (!e2eUserIds.has(u)) {
@@ -980,13 +1348,14 @@ Crypto.prototype._evalDeviceListChanges = async function(deviceLists) {
/** /**
* Get a list of all the IDs of users we share an e2e room with * 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 * @returns {string[]} List of user IDs
*/ */
Crypto.prototype._getE2eUsers = function() { Crypto.prototype._getTrackedE2eUsers = async function() {
const e2eUserIds = []; const e2eUserIds = [];
for (const room of this._getE2eRooms()) { for (const room of this._getTrackedE2eRooms()) {
const members = room.getEncryptionTargetMembers(); const members = await room.getEncryptionTargetMembers();
for (const member of members) { for (const member of members) {
e2eUserIds.push(member.userId); e2eUserIds.push(member.userId);
} }
@@ -995,27 +1364,25 @@ Crypto.prototype._getE2eUsers = function() {
}; };
/** /**
* Get a list of the e2e-enabled rooms we are members of * 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[]} * @returns {module:models.Room[]}
*/ */
Crypto.prototype._getE2eRooms = function() { Crypto.prototype._getTrackedE2eRooms = function() {
return this._clientStore.getRooms().filter((room) => { return this._clientStore.getRooms().filter((room) => {
// check for rooms with encryption enabled // check for rooms with encryption enabled
const alg = this._roomEncryptors[room.roomId]; const alg = this._roomEncryptors[room.roomId];
if (!alg) { if (!alg) {
return false; return false;
} }
if (!this._roomDeviceTrackingState[room.roomId]) {
// ignore any rooms which we have left
const me = room.getMember(this._userId);
if (!me || (
me.membership !== "join" && me.membership !== "invite"
)) {
return false; return false;
} }
return true; // ignore any rooms which we have left
const myMembership = room.getMyMembership();
return myMembership === "join" || myMembership === "invite";
}); });
}; };
@@ -1027,6 +1394,8 @@ Crypto.prototype._onToDeviceEvent = function(event) {
this._onRoomKeyEvent(event); this._onRoomKeyEvent(event);
} else if (event.getType() == "m.room_key_request") { } else if (event.getType() == "m.room_key_request") {
this._onRoomKeyRequestEvent(event); this._onRoomKeyRequestEvent(event);
} else if (event.getContent().msgtype === "m.bad.encrypted") {
this._onToDeviceBadEncrypted(event);
} else if (event.isBeingDecrypted()) { } else if (event.isBeingDecrypted()) {
// once the event has been decrypted, try again // once the event has been decrypted, try again
event.once('Event.decrypted', (ev) => { event.once('Event.decrypted', (ev) => {
@@ -1034,7 +1403,7 @@ Crypto.prototype._onToDeviceEvent = function(event) {
}); });
} }
} catch (e) { } catch (e) {
console.error("Error handling toDeviceEvent:", e); logger.error("Error handling toDeviceEvent:", e);
} }
}; };
@@ -1048,14 +1417,109 @@ Crypto.prototype._onRoomKeyEvent = function(event) {
const content = event.getContent(); const content = event.getContent();
if (!content.room_id || !content.algorithm) { if (!content.room_id || !content.algorithm) {
console.error("key event is missing fields"); logger.error("key event is missing fields");
return; 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); const alg = this._getRoomDecryptor(content.room_id, content.algorithm);
alg.onRoomKeyEvent(event); alg.onRoomKeyEvent(event);
}; };
/**
* Handle a toDevice event that couldn't be decrypted
*
* @private
* @param {module:models/event.MatrixEvent} event undecryptable event
*/
Crypto.prototype._onToDeviceBadEncrypted = async function(event) {
const content = event.getWireContent();
const sender = event.getSender();
const algorithm = content.algorithm;
const deviceKey = content.sender_key;
if (sender === undefined || deviceKey === undefined || deviceKey === undefined) {
return;
}
// check when we last forced a new session with this device: if we've already done so
// recently, don't do it again.
this._lastNewSessionForced[sender] = this._lastNewSessionForced[sender] || {};
const lastNewSessionForced = this._lastNewSessionForced[sender][deviceKey] || 0;
if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) {
logger.debug(
"New session already forced with device " + sender + ":" + deviceKey +
" at " + lastNewSessionForced + ": not forcing another",
);
return;
}
// establish a new olm session with this device since we're failing to decrypt messages
// on a current session.
// Note that an undecryptable message from another device could easily be spoofed -
// is there anything we can do to mitigate this?
const device = this._deviceList.getDeviceByIdentityKey(algorithm, deviceKey);
if (!device) {
logger.info(
"Couldn't find device for identity key " + deviceKey +
": not re-establishing session",
);
return;
}
const devicesByUser = {};
devicesByUser[sender] = [device];
await olmlib.ensureOlmSessionsForDevices(
this._olmDevice, this._baseApis, devicesByUser, true,
);
this._lastNewSessionForced[sender][deviceKey] = Date.now();
// Now send a blank message on that session so the other side knows about it.
// (The keyshare request is sent in the clear so that won't do)
// We send this first such that, as long as the toDevice messages arrive in the
// same order we sent them, the other end will get this first, set up the new session,
// then get the keyshare request and send the key over this new session (because it
// is the session it has most recently received a message on).
const encryptedContent = {
algorithm: olmlib.OLM_ALGORITHM,
sender_key: this._olmDevice.deviceCurve25519Key,
ciphertext: {},
};
await olmlib.encryptMessageForDevice(
encryptedContent.ciphertext,
this._userId,
this._deviceId,
this._olmDevice,
sender,
device,
{type: "m.dummy"},
);
await this._baseApis.sendToDevice("m.room.encrypted", {
[sender]: {
[device.deviceId]: encryptedContent,
},
});
// Most of the time this probably won't be necessary since we'll have queued up a key request when
// we failed to decrypt the message and will be waiting a bit for the key to arrive before sending
// it. This won't always be the case though so we need to re-send any that have already been sent
// to avoid races.
const requestsToResend =
await this._outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest(
sender, device.deviceId,
);
for (const keyReq of requestsToResend) {
this.cancelRoomKeyRequest(keyReq.requestBody, true);
}
};
/** /**
* Handle a change in the membership state of a member of a room * Handle a change in the membership state of a member of a room
* *
@@ -1080,16 +1544,21 @@ Crypto.prototype._onRoomMembership = function(event, member, oldMembership) {
// not encrypting in this room // not encrypting in this room
return; 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') { if (member.membership == 'join') {
console.log('Join event for ' + member.userId + ' in ' + roomId); logger.log('Join event for ' + member.userId + ' in ' + roomId);
// make sure we are tracking the deviceList for this user // make sure we are tracking the deviceList for this user
this._deviceList.startTrackingDeviceList(member.userId); this._deviceList.startTrackingDeviceList(member.userId);
} else if (member.membership == 'invite' && } else if (member.membership == 'invite' &&
this._clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) { this._clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) {
console.log('Invite event for ' + member.userId + ' in ' + roomId); logger.log('Invite event for ' + member.userId + ' in ' + roomId);
this._deviceList.startTrackingDeviceList(member.userId); this._deviceList.startTrackingDeviceList(member.userId);
} }
}
alg.onRoomMembership(event, member, oldMembership); alg.onRoomMembership(event, member, oldMembership);
}; };
@@ -1153,7 +1622,7 @@ Crypto.prototype._processReceivedRoomKeyRequests = async function() {
this._processReceivedRoomKeyRequestCancellation(cancellation), this._processReceivedRoomKeyRequestCancellation(cancellation),
); );
} catch (e) { } catch (e) {
console.error(`Error processing room key requsts: ${e}`); logger.error(`Error processing room key requsts: ${e}`);
} finally { } finally {
this._processingRoomKeyRequests = false; this._processingRoomKeyRequests = false;
} }
@@ -1172,13 +1641,31 @@ Crypto.prototype._processReceivedRoomKeyRequest = async function(req) {
const roomId = body.room_id; const roomId = body.room_id;
const alg = body.algorithm; const alg = body.algorithm;
console.log(`m.room_key_request from ${userId}:${deviceId}` + logger.log(`m.room_key_request from ${userId}:${deviceId}` +
` for ${roomId} / ${body.session_id} (id ${req.requestId})`); ` for ${roomId} / ${body.session_id} (id ${req.requestId})`);
if (userId !== this._userId) { if (userId !== this._userId) {
// TODO: determine if we sent this device the keys already: in if (!this._roomEncryptors[roomId]) {
// which case we can do so again. logger.debug(`room key request for unencrypted room ${roomId}`);
console.log("Ignoring room key request from other user for now"); 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; return;
} }
@@ -1188,18 +1675,18 @@ Crypto.prototype._processReceivedRoomKeyRequest = async function(req) {
// if we don't have a decryptor for this room/alg, we don't have // 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. // the keys for the requested events, and can drop the requests.
if (!this._roomDecryptors[roomId]) { if (!this._roomDecryptors[roomId]) {
console.log(`room key request for unencrypted room ${roomId}`); logger.log(`room key request for unencrypted room ${roomId}`);
return; return;
} }
const decryptor = this._roomDecryptors[roomId][alg]; const decryptor = this._roomDecryptors[roomId][alg];
if (!decryptor) { if (!decryptor) {
console.log(`room key request for unknown alg ${alg} in room ${roomId}`); logger.log(`room key request for unknown alg ${alg} in room ${roomId}`);
return; return;
} }
if (!await decryptor.hasKeysForKeyRequest(req)) { if (!await decryptor.hasKeysForKeyRequest(req)) {
console.log( logger.log(
`room key request for unknown session ${roomId} / ` + `room key request for unknown session ${roomId} / ` +
body.session_id, body.session_id,
); );
@@ -1213,7 +1700,7 @@ Crypto.prototype._processReceivedRoomKeyRequest = async function(req) {
// if the device is is verified already, share the keys // if the device is is verified already, share the keys
const device = this._deviceList.getStoredDevice(userId, deviceId); const device = this._deviceList.getStoredDevice(userId, deviceId);
if (device && device.isVerified()) { if (device && device.isVerified()) {
console.log('device is already verified: sharing keys'); logger.log('device is already verified: sharing keys');
req.share(); req.share();
return; return;
} }
@@ -1230,7 +1717,7 @@ Crypto.prototype._processReceivedRoomKeyRequest = async function(req) {
Crypto.prototype._processReceivedRoomKeyRequestCancellation = async function( Crypto.prototype._processReceivedRoomKeyRequestCancellation = async function(
cancellation, cancellation,
) { ) {
console.log( logger.log(
`m.room_key_request cancellation for ${cancellation.userId}:` + `m.room_key_request cancellation for ${cancellation.userId}:` +
`${cancellation.deviceId} (id ${cancellation.requestId})`, `${cancellation.deviceId} (id ${cancellation.requestId})`,
); );
@@ -1415,6 +1902,3 @@ class IncomingRoomKeyRequestCancellation {
* @event module:client~MatrixClient#"crypto.warning" * @event module:client~MatrixClient#"crypto.warning"
* @param {string} type One of the strings listed above * @param {string} type One of the strings listed above
*/ */
/** */
module.exports = Crypto;

View File

@@ -23,6 +23,7 @@ limitations under the License.
import Promise from 'bluebird'; import Promise from 'bluebird';
const anotherjson = require('another-json'); const anotherjson = require('another-json');
const logger = require("../logger");
const utils = require("../utils"); const utils = require("../utils");
/** /**
@@ -35,6 +36,11 @@ module.exports.OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
*/ */
module.exports.MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; module.exports.MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
/**
* matrix algorithm tag for megolm backups
*/
module.exports.MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2";
/** /**
* Encrypt an event payload for an Olm device * Encrypt an event payload for an Olm device
@@ -65,7 +71,7 @@ module.exports.encryptMessageForDevice = async function(
return; return;
} }
console.log( logger.log(
"Using sessionid " + sessionId + " for device " + "Using sessionid " + sessionId + " for device " +
recipientUserId + ":" + recipientDevice.deviceId, recipientUserId + ":" + recipientDevice.deviceId,
); );
@@ -115,14 +121,17 @@ module.exports.encryptMessageForDevice = async function(
* @param {module:base-apis~MatrixBaseApis} baseApis * @param {module:base-apis~MatrixBaseApis} baseApis
* *
* @param {object<string, module:crypto/deviceinfo[]>} devicesByUser * @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
* map from userid to list of devices * map from userid to list of devices to ensure sessions for
*
* @param {bolean} force If true, establish a new session even if one already exists.
* Optional.
* *
* @return {module:client.Promise} resolves once the sessions are complete, to * @return {module:client.Promise} resolves once the sessions are complete, to
* an Object mapping from userId to deviceId to * an Object mapping from userId to deviceId to
* {@link module:crypto~OlmSessionResult} * {@link module:crypto~OlmSessionResult}
*/ */
module.exports.ensureOlmSessionsForDevices = async function( module.exports.ensureOlmSessionsForDevices = async function(
olmDevice, baseApis, devicesByUser, olmDevice, baseApis, devicesByUser, force,
) { ) {
const devicesWithoutSession = [ const devicesWithoutSession = [
// [userId, deviceId], ... // [userId, deviceId], ...
@@ -140,7 +149,7 @@ module.exports.ensureOlmSessionsForDevices = async function(
const deviceId = deviceInfo.deviceId; const deviceId = deviceInfo.deviceId;
const key = deviceInfo.getIdentityKey(); const key = deviceInfo.getIdentityKey();
const sessionId = await olmDevice.getSessionIdForDevice(key); const sessionId = await olmDevice.getSessionIdForDevice(key);
if (sessionId === null) { if (sessionId === null || force) {
devicesWithoutSession.push([userId, deviceId]); devicesWithoutSession.push([userId, deviceId]);
} }
result[userId][deviceId] = { result[userId][deviceId] = {
@@ -176,7 +185,7 @@ module.exports.ensureOlmSessionsForDevices = async function(
for (let j = 0; j < devices.length; j++) { for (let j = 0; j < devices.length; j++) {
const deviceInfo = devices[j]; const deviceInfo = devices[j];
const deviceId = deviceInfo.deviceId; const deviceId = deviceInfo.deviceId;
if (result[userId][deviceId].sessionId) { if (result[userId][deviceId].sessionId && !force) {
// we already have a result for this device // we already have a result for this device
continue; continue;
} }
@@ -190,7 +199,7 @@ module.exports.ensureOlmSessionsForDevices = async function(
} }
if (!oneTimeKey) { if (!oneTimeKey) {
console.warn( logger.warn(
"No one-time keys (alg=" + oneTimeKeyAlgorithm + "No one-time keys (alg=" + oneTimeKeyAlgorithm +
") for device " + userId + ":" + deviceId, ") for device " + userId + ":" + deviceId,
); );
@@ -219,7 +228,7 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn
deviceInfo.getFingerprint(), deviceInfo.getFingerprint(),
); );
} catch (e) { } catch (e) {
console.error( logger.error(
"Unable to verify signature on one-time key for device " + "Unable to verify signature on one-time key for device " +
userId + ":" + deviceId + ":", e, userId + ":" + deviceId + ":", e,
); );
@@ -233,12 +242,12 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn
); );
} catch (e) { } catch (e) {
// possibly a bad key // possibly a bad key
console.error("Error starting session with device " + logger.error("Error starting session with device " +
userId + ":" + deviceId + ": " + e); userId + ":" + deviceId + ": " + e);
return null; return null;
} }
console.log("Started new sessionid " + sid + logger.log("Started new sessionid " + sid +
" for device " + userId + ":" + deviceId); " for device " + userId + ":" + deviceId);
return sid; return sid;
} }

66
src/crypto/recoverykey.js Normal file
View File

@@ -0,0 +1,66 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import bs58 from 'bs58';
// picked arbitrarily but to try & avoid clashing with any bitcoin ones
// (which are also base58 encoded, but bitcoin's involve a lot more hashing)
const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01];
export function encodeRecoveryKey(key) {
const buf = new Uint8Array(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1);
buf.set(OLM_RECOVERY_KEY_PREFIX, 0);
buf.set(key, OLM_RECOVERY_KEY_PREFIX.length);
let parity = 0;
for (let i = 0; i < buf.length - 1; ++i) {
parity ^= buf[i];
}
buf[buf.length - 1] = parity;
const base58key = bs58.encode(buf);
return base58key.match(/.{1,4}/g).join(" ");
}
export function decodeRecoveryKey(recoverykey) {
const result = bs58.decode(recoverykey.replace(/ /g, ''));
let parity = 0;
for (const b of result) {
parity ^= b;
}
if (parity !== 0) {
throw new Error("Incorrect parity");
}
for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) {
if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) {
throw new Error("Incorrect prefix");
}
}
if (
result.length !==
OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1
) {
throw new Error("Incorrect length");
}
return result.slice(
OLM_RECOVERY_KEY_PREFIX.length,
OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH,
);
}

View File

@@ -16,9 +16,11 @@ limitations under the License.
*/ */
import Promise from 'bluebird'; import Promise from 'bluebird';
import logger from '../../logger';
import utils from '../../utils'; import utils from '../../utils';
export const VERSION = 6; export const VERSION = 7;
/** /**
* Implementation of a CryptoStore which is backed by an existing * Implementation of a CryptoStore which is backed by an existing
@@ -38,7 +40,7 @@ export class Backend {
// attempts to delete the database will block (and subsequent // attempts to delete the database will block (and subsequent
// attempts to re-create it will also block). // attempts to re-create it will also block).
db.onversionchange = (ev) => { db.onversionchange = (ev) => {
console.log(`versionchange for indexeddb ${this._dbName}: closing`); logger.log(`versionchange for indexeddb ${this._dbName}: closing`);
db.close(); db.close();
}; };
} }
@@ -64,7 +66,7 @@ export class Backend {
this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => {
if (existing) { if (existing) {
// this entry matches the request - return it. // this entry matches the request - return it.
console.log( logger.log(
`already have key request outstanding for ` + `already have key request outstanding for ` +
`${requestBody.room_id} / ${requestBody.session_id}: ` + `${requestBody.room_id} / ${requestBody.session_id}: ` +
`not sending another`, `not sending another`,
@@ -75,7 +77,7 @@ export class Backend {
// we got to the end of the list without finding a match // we got to the end of the list without finding a match
// - add the new request. // - add the new request.
console.log( logger.log(
`enqueueing key request for ${requestBody.room_id} / ` + `enqueueing key request for ${requestBody.room_id} / ` +
requestBody.session_id, requestBody.session_id,
); );
@@ -204,6 +206,42 @@ export class Backend {
return promiseifyTxn(txn).then(() => result); return promiseifyTxn(txn).then(() => result);
} }
getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
let stateIndex = 0;
const results = [];
function onsuccess(ev) {
const cursor = ev.target.result;
if (cursor) {
const keyReq = cursor.value;
if (keyReq.recipients.includes({userId, deviceId})) {
results.push(keyReq);
}
cursor.continue();
} else {
// try the next state in the list
stateIndex++;
if (stateIndex >= wantedStates.length) {
// no matches
return;
}
const wantedState = wantedStates[stateIndex];
const cursorReq = ev.target.source.openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
}
}
const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly");
const store = txn.objectStore("outgoingRoomKeyRequests");
const wantedState = wantedStates[stateIndex];
const cursorReq = store.index("state").openCursor(wantedState);
cursorReq.onsuccess = onsuccess;
return promiseifyTxn(txn).then(() => results);
}
/** /**
* Look for an existing room key request by id and state, and update it if * Look for an existing room key request by id and state, and update it if
* found * found
@@ -226,7 +264,7 @@ export class Backend {
} }
const data = cursor.value; const data = cursor.value;
if (data.state != expectedState) { if (data.state != expectedState) {
console.warn( logger.warn(
`Cannot update room key request from ${expectedState} ` + `Cannot update room key request from ${expectedState} ` +
`as it was already updated to ${data.state}`, `as it was already updated to ${data.state}`,
); );
@@ -264,7 +302,7 @@ export class Backend {
} }
const data = cursor.value; const data = cursor.value;
if (data.state != expectedState) { if (data.state != expectedState) {
console.warn( logger.warn(
`Cannot delete room key request in state ${data.state} ` `Cannot delete room key request in state ${data.state} `
+ `(expected ${expectedState})`, + `(expected ${expectedState})`,
); );
@@ -312,7 +350,10 @@ export class Backend {
getReq.onsuccess = function() { getReq.onsuccess = function() {
const cursor = getReq.result; const cursor = getReq.result;
if (cursor) { if (cursor) {
results[cursor.value.sessionId] = cursor.value.session; results[cursor.value.sessionId] = {
session: cursor.value.session,
lastReceivedMessageTs: cursor.value.lastReceivedMessageTs,
};
cursor.continue(); cursor.continue();
} else { } else {
try { try {
@@ -330,7 +371,10 @@ export class Backend {
getReq.onsuccess = function() { getReq.onsuccess = function() {
try { try {
if (getReq.result) { if (getReq.result) {
func(getReq.result.session); func({
session: getReq.result.session,
lastReceivedMessageTs: getReq.result.lastReceivedMessageTs,
});
} else { } else {
func(null); func(null);
} }
@@ -340,9 +384,14 @@ export class Backend {
}; };
} }
storeEndToEndSession(deviceKey, sessionId, session, txn) { storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
const objectStore = txn.objectStore("sessions"); const objectStore = txn.objectStore("sessions");
objectStore.put({deviceKey, sessionId, session}); objectStore.put({
deviceKey,
sessionId,
session: sessionInfo.session,
lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs,
});
} }
// Inbound group sessions // Inbound group sessions
@@ -400,7 +449,7 @@ export class Backend {
ev.stopPropagation(); ev.stopPropagation();
// ...and this stops it from aborting the transaction // ...and this stops it from aborting the transaction
ev.preventDefault(); ev.preventDefault();
console.log( logger.log(
"Ignoring duplicate inbound group session: " + "Ignoring duplicate inbound group session: " +
senderCurve25519Key + " / " + sessionId, senderCurve25519Key + " / " + sessionId,
); );
@@ -460,6 +509,71 @@ export class Backend {
}; };
} }
// 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();
}
}
};
});
}
unmarkSessionsNeedingBackup(sessions) {
const txn = this._db.transaction("sessions_needing_backup", "readwrite");
const objectStore = txn.objectStore("sessions_needing_backup");
return Promise.all(sessions.map((session) => {
return new Promise((resolve, reject) => {
const req = objectStore.delete([session.senderKey, session.sessionId]);
req.onsuccess = resolve;
req.onerror = reject;
});
}));
}
markSessionsNeedingBackup(sessions, txn) {
if (!txn) {
txn = this._db.transaction("sessions_needing_backup", "readwrite");
}
const objectStore = txn.objectStore("sessions_needing_backup");
return Promise.all(sessions.map((session) => {
return new Promise((resolve, reject) => {
const req = objectStore.put({
senderCurve25519Key: session.senderKey,
sessionId: session.sessionId,
});
req.onsuccess = resolve;
req.onerror = reject;
});
}));
}
doTxn(mode, stores, func) { doTxn(mode, stores, func) {
const txn = this._db.transaction(stores, mode); const txn = this._db.transaction(stores, mode);
const promise = promiseifyTxn(txn); const promise = promiseifyTxn(txn);
@@ -471,7 +585,7 @@ export class Backend {
} }
export function upgradeDatabase(db, oldVersion) { export function upgradeDatabase(db, oldVersion) {
console.log( logger.log(
`Upgrading IndexedDBCryptoStore from version ${oldVersion}` `Upgrading IndexedDBCryptoStore from version ${oldVersion}`
+ ` to ${VERSION}`, + ` to ${VERSION}`,
); );
@@ -498,6 +612,11 @@ export function upgradeDatabase(db, oldVersion) {
if (oldVersion < 6) { if (oldVersion < 6) {
db.createObjectStore("rooms"); db.createObjectStore("rooms");
} }
if (oldVersion < 7) {
db.createObjectStore("sessions_needing_backup", {
keyPath: ["senderCurve25519Key", "sessionId"],
});
}
// Expand as needed. // Expand as needed.
} }

View File

@@ -17,9 +17,11 @@ limitations under the License.
import Promise from 'bluebird'; import Promise from 'bluebird';
import logger from '../../logger';
import LocalStorageCryptoStore from './localStorage-crypto-store'; import LocalStorageCryptoStore from './localStorage-crypto-store';
import MemoryCryptoStore from './memory-crypto-store'; import MemoryCryptoStore from './memory-crypto-store';
import * as IndexedDBCryptoStoreBackend from './indexeddb-crypto-store-backend'; import * as IndexedDBCryptoStoreBackend from './indexeddb-crypto-store-backend';
import {InvalidCryptoStoreError} from '../../errors';
/** /**
* Internal module. indexeddb storage for e2e. * Internal module. indexeddb storage for e2e.
@@ -64,7 +66,7 @@ export default class IndexedDBCryptoStore {
return; return;
} }
console.log(`connecting to indexeddb ${this._dbName}`); logger.log(`connecting to indexeddb ${this._dbName}`);
const req = this._indexedDB.open( const req = this._indexedDB.open(
this._dbName, IndexedDBCryptoStoreBackend.VERSION, this._dbName, IndexedDBCryptoStoreBackend.VERSION,
@@ -77,7 +79,7 @@ export default class IndexedDBCryptoStore {
}; };
req.onblocked = () => { req.onblocked = () => {
console.log( logger.log(
`can't yet open IndexedDBCryptoStore because it is open elsewhere`, `can't yet open IndexedDBCryptoStore because it is open elsewhere`,
); );
}; };
@@ -89,20 +91,42 @@ export default class IndexedDBCryptoStore {
req.onsuccess = (r) => { req.onsuccess = (r) => {
const db = r.target.result; const db = r.target.result;
console.log(`connected to indexeddb ${this._dbName}`); logger.log(`connected to indexeddb ${this._dbName}`);
resolve(new IndexedDBCryptoStoreBackend.Backend(db)); resolve(new IndexedDBCryptoStoreBackend.Backend(db));
}; };
}).then((backend) => {
// Edge has IndexedDB but doesn't support compund keys which we use fairly extensively.
// Try a dummy query which will fail if the browser doesn't support compund keys, so
// we can fall back to a different backend.
return backend.doTxn(
'readonly',
[IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS],
(txn) => {
backend.getEndToEndInboundGroupSession('', '', txn, () => {});
}).then(() => {
return backend;
},
);
}).catch((e) => { }).catch((e) => {
console.warn( 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}` + `unable to connect to indexeddb ${this._dbName}` +
`: falling back to localStorage store: ${e}`, `: falling back to localStorage store: ${e}`,
); );
try {
return new LocalStorageCryptoStore(global.localStorage); return new LocalStorageCryptoStore(global.localStorage);
}).catch((e) => { } catch (e) {
console.warn( logger.warn(
`unable to open localStorage: falling back to in-memory store: ${e}`, `unable to open localStorage: falling back to in-memory store: ${e}`,
); );
return new MemoryCryptoStore(); return new MemoryCryptoStore();
}
}); });
return this._backendPromise; return this._backendPromise;
@@ -120,11 +144,11 @@ export default class IndexedDBCryptoStore {
return; return;
} }
console.log(`Removing indexeddb instance: ${this._dbName}`); logger.log(`Removing indexeddb instance: ${this._dbName}`);
const req = this._indexedDB.deleteDatabase(this._dbName); const req = this._indexedDB.deleteDatabase(this._dbName);
req.onblocked = () => { req.onblocked = () => {
console.log( logger.log(
`can't yet delete IndexedDBCryptoStore because it is open elsewhere`, `can't yet delete IndexedDBCryptoStore because it is open elsewhere`,
); );
}; };
@@ -134,14 +158,14 @@ export default class IndexedDBCryptoStore {
}; };
req.onsuccess = () => { req.onsuccess = () => {
console.log(`Removed indexeddb instance: ${this._dbName}`); logger.log(`Removed indexeddb instance: ${this._dbName}`);
resolve(); resolve();
}; };
}).catch((e) => { }).catch((e) => {
// in firefox, with indexedDB disabled, this fails with a // in firefox, with indexedDB disabled, this fails with a
// DOMError. We treat this as non-fatal, so that people can // DOMError. We treat this as non-fatal, so that people can
// still use the app. // still use the app.
console.warn(`unable to delete IndexedDBCryptoStore: ${e}`); logger.warn(`unable to delete IndexedDBCryptoStore: ${e}`);
}); });
} }
@@ -193,6 +217,24 @@ export default class IndexedDBCryptoStore {
}); });
} }
/**
* Look for room key requests by target device and state
*
* @param {string} userId Target user ID
* @param {string} deviceId Target device ID
* @param {Array<Number>} wantedStates list of acceptable states
*
* @return {Promise} resolves to a list of all the
* {@link module:crypto/store/base~OutgoingRoomKeyRequest}
*/
getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
return this._connect().then((backend) => {
return backend.getOutgoingRoomKeyRequestsByTarget(
userId, deviceId, wantedStates,
);
});
}
/** /**
* Look for an existing room key request by id and state, and update it if * Look for an existing room key request by id and state, and update it if
* found * found
@@ -270,7 +312,10 @@ export default class IndexedDBCryptoStore {
* @param {string} sessionId The ID of the session to retrieve * @param {string} sessionId The ID of the session to retrieve
* @param {*} txn An active transaction. See doTxn(). * @param {*} txn An active transaction. See doTxn().
* @param {function(object)} func Called with A map from sessionId * @param {function(object)} func Called with A map from sessionId
* to Base64 end-to-end session. * 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) { getEndToEndSession(deviceKey, sessionId, txn, func) {
this._backendPromise.value().getEndToEndSession(deviceKey, sessionId, txn, func); this._backendPromise.value().getEndToEndSession(deviceKey, sessionId, txn, func);
@@ -282,7 +327,10 @@ export default class IndexedDBCryptoStore {
* @param {string} deviceKey The public key of the other device. * @param {string} deviceKey The public key of the other device.
* @param {*} txn An active transaction. See doTxn(). * @param {*} txn An active transaction. See doTxn().
* @param {function(object)} func Called with A map from sessionId * @param {function(object)} func Called with A map from sessionId
* to Base64 end-to-end session. * 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) { getEndToEndSessions(deviceKey, txn, func) {
this._backendPromise.value().getEndToEndSessions(deviceKey, txn, func); this._backendPromise.value().getEndToEndSessions(deviceKey, txn, func);
@@ -292,12 +340,12 @@ export default class IndexedDBCryptoStore {
* Store a session between the logged-in user and another device * Store a session between the logged-in user and another device
* @param {string} deviceKey The public key of the other device. * @param {string} deviceKey The public key of the other device.
* @param {string} sessionId The ID for this end-to-end session. * @param {string} sessionId The ID for this end-to-end session.
* @param {string} session Base64 encoded end-to-end session. * @param {string} sessionInfo Session information object
* @param {*} txn An active transaction. See doTxn(). * @param {*} txn An active transaction. See doTxn().
*/ */
storeEndToEndSession(deviceKey, sessionId, session, txn) { storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
this._backendPromise.value().storeEndToEndSession( this._backendPromise.value().storeEndToEndSession(
deviceKey, sessionId, session, txn, deviceKey, sessionId, sessionInfo, txn,
); );
} }
@@ -407,6 +455,43 @@ export default class IndexedDBCryptoStore {
this._backendPromise.value().getEndToEndRooms(txn, func); this._backendPromise.value().getEndToEndRooms(txn, func);
} }
// session backups
/**
* Get the inbound group sessions that need to be backed up.
* @param {integer} limit The maximum number of sessions to retrieve. 0
* for no limit.
* @returns {Promise} resolves to an array of inbound group sessions
*/
getSessionsNeedingBackup(limit) {
return this._connect().then((backend) => {
return backend.getSessionsNeedingBackup(limit);
});
}
/**
* Unmark sessions as needing to be backed up.
* @param {Array<object>} sessions The sessions that need to be backed up.
* @returns {Promise} resolves when the sessions are unmarked
*/
unmarkSessionsNeedingBackup(sessions) {
return this._connect().then((backend) => {
return backend.unmarkSessionsNeedingBackup(sessions);
});
}
/**
* Mark sessions as needing to be backed up.
* @param {Array<object>} sessions The sessions that need to be backed up.
* @param {*} txn An active transaction. See doTxn(). (optional)
* @returns {Promise} resolves when the sessions are marked
*/
markSessionsNeedingBackup(sessions, txn) {
return this._connect().then((backend) => {
return backend.markSessionsNeedingBackup(sessions, txn);
});
}
/** /**
* Perform a transaction on the crypto store. Any store methods * Perform a transaction on the crypto store. Any store methods
* that require a transaction (txn) object to be passed in may * that require a transaction (txn) object to be passed in may
@@ -440,3 +525,4 @@ IndexedDBCryptoStore.STORE_SESSIONS = 'sessions';
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions';
IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data'; IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data';
IndexedDBCryptoStore.STORE_ROOMS = 'rooms'; IndexedDBCryptoStore.STORE_ROOMS = 'rooms';
IndexedDBCryptoStore.STORE_BACKUP = 'sessions_needing_backup';

View File

@@ -15,6 +15,8 @@ limitations under the License.
*/ */
import Promise from 'bluebird'; import Promise from 'bluebird';
import logger from '../../logger';
import MemoryCryptoStore from './memory-crypto-store.js'; import MemoryCryptoStore from './memory-crypto-store.js';
/** /**
@@ -32,6 +34,7 @@ const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; const KEY_DEVICE_DATA = E2E_PREFIX + "device_data";
const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/";
const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/"; const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/";
const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup";
function keyEndToEndSessions(deviceKey) { function keyEndToEndSessions(deviceKey) {
return E2E_PREFIX + "sessions/" + deviceKey; return E2E_PREFIX + "sessions/" + deviceKey;
@@ -65,7 +68,21 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore {
} }
_getEndToEndSessions(deviceKey, txn, func) { _getEndToEndSessions(deviceKey, txn, func) {
return getJsonItem(this.store, keyEndToEndSessions(deviceKey)); 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) { getEndToEndSession(deviceKey, sessionId, txn, func) {
@@ -77,9 +94,9 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore {
func(this._getEndToEndSessions(deviceKey) || {}); func(this._getEndToEndSessions(deviceKey) || {});
} }
storeEndToEndSession(deviceKey, sessionId, session, txn) { storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
const sessions = this._getEndToEndSessions(deviceKey) || {}; const sessions = this._getEndToEndSessions(deviceKey) || {};
sessions[sessionId] = session; sessions[sessionId] = sessionInfo;
setJsonItem( setJsonItem(
this.store, keyEndToEndSessions(deviceKey), sessions, this.store, keyEndToEndSessions(deviceKey), sessions,
); );
@@ -165,6 +182,58 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore {
func(result); 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);
}
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. * Delete all data from this store.
* *
@@ -199,8 +268,8 @@ function getJsonItem(store, key) {
// JSON.parse(null) === null, so this returns null. // JSON.parse(null) === null, so this returns null.
return JSON.parse(store.getItem(key)); return JSON.parse(store.getItem(key));
} catch (e) { } catch (e) {
console.log("Error: Failed to get key %s: %s", key, e.stack || e); logger.log("Error: Failed to get key %s: %s", key, e.stack || e);
console.log(e.stack); logger.log(e.stack);
} }
return null; return null;
} }

View File

@@ -17,6 +17,7 @@ limitations under the License.
import Promise from 'bluebird'; import Promise from 'bluebird';
import logger from '../../logger';
import utils from '../../utils'; import utils from '../../utils';
/** /**
@@ -41,6 +42,8 @@ export default class MemoryCryptoStore {
this._deviceData = null; this._deviceData = null;
// roomId -> Opaque roomInfo object // roomId -> Opaque roomInfo object
this._rooms = {}; this._rooms = {};
// Set of {senderCurve25519Key+'/'+sessionId}
this._sessionsNeedingBackup = {};
} }
/** /**
@@ -71,7 +74,7 @@ export default class MemoryCryptoStore {
if (existing) { if (existing) {
// this entry matches the request - return it. // this entry matches the request - return it.
console.log( logger.log(
`already have key request outstanding for ` + `already have key request outstanding for ` +
`${requestBody.room_id} / ${requestBody.session_id}: ` + `${requestBody.room_id} / ${requestBody.session_id}: ` +
`not sending another`, `not sending another`,
@@ -81,7 +84,7 @@ export default class MemoryCryptoStore {
// we got to the end of the list without finding a match // we got to the end of the list without finding a match
// - add the new request. // - add the new request.
console.log( logger.log(
`enqueueing key request for ${requestBody.room_id} / ` + `enqueueing key request for ${requestBody.room_id} / ` +
requestBody.session_id, requestBody.session_id,
); );
@@ -144,6 +147,19 @@ export default class MemoryCryptoStore {
return Promise.resolve(null); return Promise.resolve(null);
} }
getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
const results = [];
for (const req of this._outgoingRoomKeyRequests) {
for (const state of wantedStates) {
if (req.state === state && req.recipients.includes({userId, deviceId})) {
results.push(req);
}
}
}
return Promise.resolve(results);
}
/** /**
* Look for an existing room key request by id and state, and update it if * Look for an existing room key request by id and state, and update it if
* found * found
@@ -163,7 +179,7 @@ export default class MemoryCryptoStore {
} }
if (req.state != expectedState) { if (req.state != expectedState) {
console.warn( logger.warn(
`Cannot update room key request from ${expectedState} ` + `Cannot update room key request from ${expectedState} ` +
`as it was already updated to ${req.state}`, `as it was already updated to ${req.state}`,
); );
@@ -194,7 +210,7 @@ export default class MemoryCryptoStore {
} }
if (req.state != expectedState) { if (req.state != expectedState) {
console.warn( logger.warn(
`Cannot delete room key request in state ${req.state} ` `Cannot delete room key request in state ${req.state} `
+ `(expected ${expectedState})`, + `(expected ${expectedState})`,
); );
@@ -233,13 +249,13 @@ export default class MemoryCryptoStore {
func(this._sessions[deviceKey] || {}); func(this._sessions[deviceKey] || {});
} }
storeEndToEndSession(deviceKey, sessionId, session, txn) { storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
let deviceSessions = this._sessions[deviceKey]; let deviceSessions = this._sessions[deviceKey];
if (deviceSessions === undefined) { if (deviceSessions === undefined) {
deviceSessions = {}; deviceSessions = {};
this._sessions[deviceKey] = deviceSessions; this._sessions[deviceKey] = deviceSessions;
} }
deviceSessions[sessionId] = session; deviceSessions[sessionId] = sessionInfo;
} }
// Inbound Group Sessions // Inbound Group Sessions
@@ -295,6 +311,41 @@ export default class MemoryCryptoStore {
func(this._rooms); 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);
}
unmarkSessionsNeedingBackup(sessions) {
for (const session of sessions) {
const sessionKey = session.senderKey + '/' + session.sessionId;
delete this._sessionsNeedingBackup[sessionKey];
}
return Promise.resolve();
}
markSessionsNeedingBackup(sessions) {
for (const session of sessions) {
const sessionKey = session.senderKey + '/' + session.sessionId;
this._sessionsNeedingBackup[sessionKey] = true;
}
return Promise.resolve();
}
// Session key backups
doTxn(mode, stores, func) { doTxn(mode, stores, func) {
return Promise.resolve(func(null)); return Promise.resolve(func(null));
} }

46
src/errors.js Normal file
View File

@@ -0,0 +1,46 @@
// can't just do InvalidStoreError extends Error
// because of http://babeljs.io/docs/usage/caveats/#classes
export function InvalidStoreError(reason, value) {
const message = `Store is invalid because ${reason}, ` +
`please stop the client, delete all data and start the client again`;
const instance = Reflect.construct(Error, [message]);
Reflect.setPrototypeOf(instance, Reflect.getPrototypeOf(this));
instance.reason = reason;
instance.value = value;
return instance;
}
InvalidStoreError.TOGGLED_LAZY_LOADING = "TOGGLED_LAZY_LOADING";
InvalidStoreError.prototype = Object.create(Error.prototype, {
constructor: {
value: Error,
enumerable: false,
writable: true,
configurable: true,
},
});
Reflect.setPrototypeOf(InvalidStoreError, Error);
export function InvalidCryptoStoreError(reason) {
const message = `Crypto store is invalid because ${reason}, ` +
`please stop the client, delete all data and start the client again`;
const instance = Reflect.construct(Error, [message]);
Reflect.setPrototypeOf(instance, Reflect.getPrototypeOf(this));
instance.reason = reason;
instance.name = 'InvalidCryptoStoreError';
return instance;
}
InvalidCryptoStoreError.TOO_NEW = "TOO_NEW";
InvalidCryptoStoreError.prototype = Object.create(Error.prototype, {
constructor: {
value: Error,
enumerable: false,
writable: true,
configurable: true,
},
});
Reflect.setPrototypeOf(InvalidCryptoStoreError, Error);

View File

@@ -51,6 +51,17 @@ function Filter(userId, filterId) {
this.definition = {}; this.definition = {};
} }
Filter.LAZY_LOADING_MESSAGES_FILTER = {
lazy_load_members: true,
};
Filter.LAZY_LOADING_SYNC_FILTER = {
room: {
state: Filter.LAZY_LOADING_MESSAGES_FILTER,
},
};
/** /**
* Get the ID of this filter on your homeserver (if known) * Get the ID of this filter on your homeserver (if known)
* @return {?Number} The filter ID * @return {?Number} The filter ID

View File

@@ -752,6 +752,8 @@ module.exports.MatrixHttpApi.prototype = {
method: method, method: method,
withCredentials: false, withCredentials: false,
qs: queryParams, qs: queryParams,
qsStringifyOptions: opts.qsStringifyOptions,
useQuerystring: true,
body: data, body: data,
json: false, json: false,
timeout: localTimeoutMs, timeout: localTimeoutMs,

36
src/logger.js Normal file
View File

@@ -0,0 +1,36 @@
/*
Copyright 2018 André Jaenisch
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* @module logger
*/
const log = require("loglevel");
// This is to demonstrate, that you can use any namespace you want.
// Namespaces allow you to turn on/off the logging for specific parts of the
// application.
// An idea would be to control this via an environment variable (on Node.js).
// See https://www.npmjs.com/package/debug to see how this could be implemented
// Part of #332 is introducing a logging library in the first place.
const DEFAULT_NAME_SPACE = "matrix";
const logger = log.getLogger(DEFAULT_NAME_SPACE);
logger.setLevel(log.levels.DEBUG);
/**
* Drop-in replacement for <code>console</code> using {@link https://www.npmjs.com/package/loglevel|loglevel}.
* Can be tailored down to specific use cases if needed.
*/
module.exports = logger;

View File

@@ -34,6 +34,8 @@ module.exports.SyncAccumulator = require("./sync-accumulator");
module.exports.MatrixHttpApi = require("./http-api").MatrixHttpApi; module.exports.MatrixHttpApi = require("./http-api").MatrixHttpApi;
/** The {@link module:http-api.MatrixError|MatrixError} class. */ /** The {@link module:http-api.MatrixError|MatrixError} class. */
module.exports.MatrixError = require("./http-api").MatrixError; module.exports.MatrixError = require("./http-api").MatrixError;
/** The {@link module:errors.InvalidStoreError|InvalidStoreError} class. */
module.exports.InvalidStoreError = require("./errors").InvalidStoreError;
/** The {@link module:client.MatrixClient|MatrixClient} class. */ /** The {@link module:client.MatrixClient|MatrixClient} class. */
module.exports.MatrixClient = require("./client").MatrixClient; module.exports.MatrixClient = require("./client").MatrixClient;
/** The {@link module:models/room|Room} class. */ /** The {@link module:models/room|Room} class. */
@@ -65,6 +67,8 @@ module.exports.Filter = require("./filter");
module.exports.TimelineWindow = require("./timeline-window").TimelineWindow; module.exports.TimelineWindow = require("./timeline-window").TimelineWindow;
/** The {@link module:interactive-auth} class. */ /** The {@link module:interactive-auth} class. */
module.exports.InteractiveAuth = require("./interactive-auth"); module.exports.InteractiveAuth = require("./interactive-auth");
/** The {@link module:auto-discovery|AutoDiscovery} class. */
module.exports.AutoDiscovery = require("./autodiscovery").AutoDiscovery;
module.exports.MemoryCryptoStore = module.exports.MemoryCryptoStore =

View File

@@ -168,49 +168,19 @@ EventTimelineSet.prototype.resetLiveTimeline = function(
// if timeline support is disabled, forget about the old timelines // if timeline support is disabled, forget about the old timelines
const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken; const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken;
let newTimeline; const oldTimeline = this._liveTimeline;
const newTimeline = resetAllTimelines ?
oldTimeline.forkLive(EventTimeline.FORWARDS) :
oldTimeline.fork(EventTimeline.FORWARDS);
if (resetAllTimelines) { if (resetAllTimelines) {
newTimeline = new EventTimeline(this);
this._timelines = [newTimeline]; this._timelines = [newTimeline];
this._eventIdToTimeline = {}; this._eventIdToTimeline = {};
} else { } else {
newTimeline = this.addTimeline(); this._timelines.push(newTimeline);
} }
const oldTimeline = this._liveTimeline; if (forwardPaginationToken) {
// Collect the state events from the old timeline
const evMap = oldTimeline.getState(EventTimeline.FORWARDS).events;
const events = [];
for (const evtype in evMap) {
if (!evMap.hasOwnProperty(evtype)) {
continue;
}
for (const stateKey in evMap[evtype]) {
if (!evMap[evtype].hasOwnProperty(stateKey)) {
continue;
}
events.push(evMap[evtype][stateKey]);
}
}
// Use those events to initialise the state of the new live timeline
newTimeline.initialiseState(events);
const freshEndState = newTimeline._endState;
// 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
newTimeline._endState = oldTimeline._endState;
// If we're not resetting all timelines, we need to fix up the old live timeline
if (!resetAllTimelines) {
// Firstly, we just stole the old timeline's end state, so it needs a new one.
// Just swap them around and give it the one we just generated for the
// new live timeline.
oldTimeline._endState = freshEndState;
// Now set the forward pagination token on the old live timeline // Now set the forward pagination token on the old live timeline
// so it can be forward-paginated. // so it can be forward-paginated.
oldTimeline.setPaginationToken( oldTimeline.setPaginationToken(

View File

@@ -106,6 +106,50 @@ EventTimeline.prototype.initialiseState = function(stateEvents) {
this._endState.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 * Get the ID of the room for this timeline
* @return {string} room ID * @return {string} room ID

View File

@@ -58,10 +58,27 @@ function RoomMember(roomId, userId) {
this.events = { this.events = {
member: null, member: null,
}; };
this._isOutOfBand = false;
this._updateModifiedTime(); this._updateModifiedTime();
} }
utils.inherits(RoomMember, EventEmitter); 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 * Update this room member's membership event. May fire "RoomMember.name" if
* this event updates this member's name. * this event updates this member's name.
@@ -75,13 +92,20 @@ RoomMember.prototype.setMembershipEvent = function(event, roomState) {
if (event.getType() !== "m.room.member") { if (event.getType() !== "m.room.member") {
return; return;
} }
this._isOutOfBand = false;
this.events.member = event; this.events.member = event;
const oldMembership = this.membership; const oldMembership = this.membership;
this.membership = event.getDirectionalContent().membership; this.membership = event.getDirectionalContent().membership;
const oldName = this.name; const oldName = this.name;
this.name = calculateDisplayName(this, event, roomState); this.name = calculateDisplayName(
this.userId,
event.getDirectionalContent().displayname,
roomState);
this.rawDisplayName = event.getDirectionalContent().displayname || this.userId; this.rawDisplayName = event.getDirectionalContent().displayname || this.userId;
if (oldMembership !== this.membership) { if (oldMembership !== this.membership) {
this._updateModifiedTime(); this._updateModifiedTime();
@@ -177,6 +201,44 @@ RoomMember.prototype.getLastModifiedTime = function() {
return this._modified; 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. * Get the avatar URL for a room member.
* @param {string} baseUrl The base homeserver URL See * @param {string} baseUrl The base homeserver URL See
@@ -200,10 +262,12 @@ RoomMember.prototype.getAvatarUrl =
if (allowDefault === undefined) { if (allowDefault === undefined) {
allowDefault = true; allowDefault = true;
} }
if (!this.events.member && !allowDefault) {
const rawUrl = this.getMxcAvatarUrl();
if (!rawUrl && !allowDefault) {
return null; return null;
} }
const rawUrl = this.events.member ? this.events.member.getContent().avatar_url : null;
const httpUrl = ContentRepo.getHttpUriForMxc( const httpUrl = ContentRepo.getHttpUriForMxc(
baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks, baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks,
); );
@@ -216,12 +280,21 @@ RoomMember.prototype.getAvatarUrl =
} }
return null; return null;
}; };
/**
* get the mxc avatar url, either from a state event, or from a lazily loaded member
* @return {string} the mxc avatar url
*/
RoomMember.prototype.getMxcAvatarUrl = function() {
if(this.events.member) {
return this.events.member.getDirectionalContent().avatar_url;
} else if(this.user) {
return this.user.avatarUrl;
}
return null;
};
function calculateDisplayName(member, event, roomState) { function calculateDisplayName(selfUserId, displayName, roomState) {
const displayName = event.getDirectionalContent().displayname; if (!displayName || displayName === selfUserId) {
const selfUserId = member.userId;
if (!displayName) {
return selfUserId; return selfUserId;
} }

View File

@@ -22,6 +22,11 @@ const EventEmitter = require("events").EventEmitter;
const utils = require("../utils"); const utils = require("../utils");
const RoomMember = require("./room-member"); const RoomMember = require("./room-member");
// 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. * Construct room state.
* *
@@ -46,13 +51,17 @@ const RoomMember = require("./room-member");
* @constructor * @constructor
* @param {?string} roomId Optional. The ID of the room which has this state. * @param {?string} roomId Optional. The ID of the room which has this state.
* If none is specified it just tracks paginationTokens, useful for notifTimelineSet * 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.<string, RoomMember>} members The room member dictionary, keyed * @prop {Object.<string, RoomMember>} members The room member dictionary, keyed
* on the user's ID. * on the user's ID.
* @prop {Object.<string, Object.<string, MatrixEvent>>} events The state * @prop {Object.<string, Object.<string, MatrixEvent>>} events The state
* events dictionary, keyed on the event type and then the state_key value. * events dictionary, keyed on the event type and then the state_key value.
* @prop {string} paginationToken The pagination token for this state. * @prop {string} paginationToken The pagination token for this state.
*/ */
function RoomState(roomId) { function RoomState(roomId, oobMemberFlags = undefined) {
this.roomId = roomId; this.roomId = roomId;
this.members = { this.members = {
// userId: RoomMember // userId: RoomMember
@@ -70,6 +79,22 @@ function RoomState(roomId) {
this._userIdsToDisplayNames = {}; this._userIdsToDisplayNames = {};
this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite
this._joinedMemberCount = null; // cache of the number of joined members 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); utils.inherits(RoomState, EventEmitter);
@@ -79,14 +104,48 @@ utils.inherits(RoomState, EventEmitter);
* @return {integer} The number of members in this room whose membership is 'join' * @return {integer} The number of members in this room whose membership is 'join'
*/ */
RoomState.prototype.getJoinedMemberCount = function() { RoomState.prototype.getJoinedMemberCount = function() {
if (this._summaryJoinedMemberCount !== null) {
return this._summaryJoinedMemberCount;
}
if (this._joinedMemberCount === null) { if (this._joinedMemberCount === null) {
this._joinedMemberCount = this.getMembers().filter((m) => { this._joinedMemberCount = this.getMembers().reduce((count, m) => {
return m.membership === 'join'; return m.membership === 'join' ? count + 1 : count;
}).length; }, 0);
} }
return this._joinedMemberCount; 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. * Get all RoomMembers in this room.
* @return {Array<RoomMember>} A list of RoomMembers. * @return {Array<RoomMember>} A list of RoomMembers.
@@ -95,6 +154,16 @@ RoomState.prototype.getMembers = function() {
return utils.values(this.members); return utils.values(this.members);
}; };
/**
* Get all RoomMembers in this room, excluding the user IDs provided.
* @param {Array<string>} excludedIds The user IDs to exclude.
* @return {Array<RoomMember>} 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. * Get a room member by their user ID.
* @param {string} userId The room member's user ID. * @param {string} userId The room member's user ID.
@@ -119,12 +188,9 @@ RoomState.prototype.getSentinelMember = function(userId) {
if (sentinel === undefined) { if (sentinel === undefined) {
sentinel = new RoomMember(this.roomId, userId); sentinel = new RoomMember(this.roomId, userId);
const membershipEvent = this.getStateEvents("m.room.member", userId); const member = this.members[userId];
if (!membershipEvent) return null; if (member) {
sentinel.setMembershipEvent(membershipEvent, this); sentinel.setMembershipEvent(member.events.member, this);
const pwrLvlEvent = this.getStateEvents("m.room.power_levels", "");
if (pwrLvlEvent) {
sentinel.setPowerLevelEvent(pwrLvlEvent);
} }
this._sentinels[userId] = sentinel; this._sentinels[userId] = sentinel;
} }
@@ -152,6 +218,67 @@ RoomState.prototype.getStateEvents = function(eventType, stateKey) {
return event ? event : null; 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;
Object.values(this.events).forEach((eventsByStateKey) => {
const eventsForType = Object.values(eventsByStateKey);
copy.setStateEvents(eventsForType);
});
// Ugly hack: see above
this._oobMemberFlags.status = status;
if (this._summaryInvitedMemberCount !== null) {
copy.setInvitedMemberCount(this.getInvitedMemberCount());
}
if (this._summaryJoinedMemberCount !== null) {
copy.setJoinedMemberCount(this.getJoinedMemberCount());
}
// copy out of band flags if needed
if (this._oobMemberFlags.status == OOB_STATUS_FINISHED) {
// copy markOutOfBand flags
this.getMembers().forEach((member) => {
if (member.isOutOfBand()) {
const copyMember = copy.getMember(member.userId);
copyMember.markOutOfBand();
}
});
}
return copy;
};
/**
* Add previously unknown state events.
* When lazy loading members while back-paginating,
* the relevant room state for the timeline chunk at the end
* of the chunk can be set with this method.
* @param {MatrixEvent[]} events state events to prepend
*/
RoomState.prototype.setUnknownStateEvents = function(events) {
const unknownStateEvents = events.filter((event) => {
return this.events[event.getType()] === undefined ||
this.events[event.getType()][event.getStateKey()] === undefined;
});
this.setStateEvents(unknownStateEvents);
};
/** /**
* Add an array of one or more state MatrixEvents, overwriting * Add an array of one or more state MatrixEvents, overwriting
* any existing state with the same {type, stateKey} tuple. Will fire * any existing state with the same {type, stateKey} tuple. Will fire
@@ -175,10 +302,7 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
return; return;
} }
if (self.events[event.getType()] === undefined) { self._setStateEvent(event);
self.events[event.getType()] = {};
}
self.events[event.getType()][event.getStateKey()] = event;
if (event.getType() === "m.room.member") { if (event.getType() === "m.room.member") {
_updateDisplayNameCache( _updateDisplayNameCache(
self, event.getStateKey(), event.getContent().displayname, self, event.getStateKey(), event.getContent().displayname,
@@ -216,24 +340,10 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
event.getPrevContent().displayname; event.getPrevContent().displayname;
} }
let member = self.members[userId]; const member = self._getOrCreateMember(userId, event);
if (!member) {
member = new RoomMember(event.getRoomId(), userId);
self.emit("RoomState.newMember", event, self, member);
}
member.setMembershipEvent(event, self); member.setMembershipEvent(event, self);
// this member may have a power level already, so set it.
const pwrLvlEvent = self.getStateEvents("m.room.power_levels", "");
if (pwrLvlEvent) {
member.setPowerLevelEvent(pwrLvlEvent);
}
// blow away the sentinel which is now outdated self._updateMember(member);
delete self._sentinels[userId];
self.members[userId] = member;
self._joinedMemberCount = null;
self.emit("RoomState.members", event, self, member); self.emit("RoomState.members", event, self, member);
} else if (event.getType() === "m.room.power_levels") { } else if (event.getType() === "m.room.power_levels") {
const members = utils.values(self.members); const members = utils.values(self.members);
@@ -248,6 +358,140 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
}); });
}; };
/**
* 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[event.getType()] === undefined) {
this.events[event.getType()] = {};
}
this.events[event.getType()][event.getStateKey()] = event;
};
RoomState.prototype._updateMember = function(member) {
// this member may have a power level already, so set it.
const pwrLvlEvent = this.getStateEvents("m.room.power_levels", "");
if (pwrLvlEvent) {
member.setPowerLevelEvent(pwrLvlEvent);
}
// blow away the sentinel which is now outdated
delete this._sentinels[member.userId];
this.members[member.userId] = member;
this._joinedMemberCount = null;
this._invitedMemberCount = null;
};
/**
* Get the out-of-band members loading state, whether loading is needed or not.
* Note that loading might be in progress and hence isn't needed.
* @return {bool} whether or not the members of this room need to be loaded
*/
RoomState.prototype.needsOutOfBandMembers = function() {
return this._oobMemberFlags.status === OOB_STATUS_NOTSTARTED;
};
/**
* Mark this room state as waiting for out-of-band members,
* ensuring it doesn't ask for them to be requested again
* through needsOutOfBandMembers
*/
RoomState.prototype.markOutOfBandMembersStarted = function() {
if (this._oobMemberFlags.status !== OOB_STATUS_NOTSTARTED) {
return;
}
this._oobMemberFlags.status = OOB_STATUS_INPROGRESS;
};
/**
* Mark this room state as having failed to fetch out-of-band members
*/
RoomState.prototype.markOutOfBandMembersFailed = function() {
if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) {
return;
}
this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
};
/**
* Clears the loaded out-of-band members
*/
RoomState.prototype.clearOutOfBandMembers = function() {
let count = 0;
Object.keys(this.members).forEach((userId) => {
const member = this.members[userId];
if (member.isOutOfBand()) {
++count;
delete this.members[userId];
}
});
console.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) {
console.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`);
if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) {
return;
}
console.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. * Set the current typing event for this room.
* @param {MatrixEvent} event The typing event * @param {MatrixEvent} event The typing event
@@ -401,11 +645,6 @@ RoomState.prototype.maySendStateEvent = function(stateEventType, userId) {
* according to the room's state. * according to the room's state.
*/ */
RoomState.prototype._maySendEventOfType = function(eventType, userId, state) { RoomState.prototype._maySendEventOfType = function(eventType, userId, state) {
const member = this.getMember(userId);
if (!member || member.membership == 'leave') {
return false;
}
const power_levels_event = this.getStateEvents('m.room.power_levels', ''); const power_levels_event = this.getStateEvents('m.room.power_levels', '');
let power_levels; let power_levels;
@@ -413,25 +652,34 @@ RoomState.prototype._maySendEventOfType = function(eventType, userId, state) {
let state_default = 0; let state_default = 0;
let events_default = 0; let events_default = 0;
let powerLevel = 0;
if (power_levels_event) { if (power_levels_event) {
power_levels = power_levels_event.getContent(); power_levels = power_levels_event.getContent();
events_levels = power_levels.events || {}; events_levels = power_levels.events || {};
if (utils.isNumber(power_levels.state_default)) { if (Number.isFinite(power_levels.state_default)) {
state_default = power_levels.state_default; state_default = power_levels.state_default;
} else { } else {
state_default = 50; state_default = 50;
} }
if (utils.isNumber(power_levels.events_default)) {
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; events_default = power_levels.events_default;
} }
} }
let required_level = state ? state_default : events_default; let required_level = state ? state_default : events_default;
if (utils.isNumber(events_levels[eventType])) { if (Number.isFinite(events_levels[eventType])) {
required_level = events_levels[eventType]; required_level = events_levels[eventType];
} }
return member.powerLevel >= required_level; return powerLevel >= required_level;
}; };
/** /**
@@ -543,7 +791,8 @@ function _updateDisplayNameCache(roomState, userId, displayName) {
/** /**
* Fires whenever a member is added to the members dictionary. The RoomMember * Fires whenever a member is added to the members dictionary. The RoomMember
* will not be fully populated yet (e.g. no membership state). * 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" * @event module:client~MatrixClient#"RoomState.newMember"
* @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {MatrixEvent} event The matrix event which caused this event to fire.
* @param {RoomState} state The room state whose RoomState.members dictionary * @param {RoomState} state The room state whose RoomState.members dictionary

View File

@@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -21,6 +22,7 @@ const EventEmitter = require("events").EventEmitter;
const EventStatus = require("./event").EventStatus; const EventStatus = require("./event").EventStatus;
const RoomSummary = require("./room-summary"); const RoomSummary = require("./room-summary");
const RoomMember = require("./room-member");
const MatrixEvent = require("./event").MatrixEvent; const MatrixEvent = require("./event").MatrixEvent;
const utils = require("../utils"); const utils = require("../utils");
const ContentRepo = require("../content-repo"); const ContentRepo = require("../content-repo");
@@ -29,6 +31,8 @@ const EventTimelineSet = require("./event-timeline-set");
import ReEmitter from '../ReEmitter'; import ReEmitter from '../ReEmitter';
const LATEST_ROOM_VERSION = '1';
function synthesizeReceipt(userId, event, receiptType) { function synthesizeReceipt(userId, event, receiptType) {
// console.log("synthesizing receipt for "+event.getId()); // console.log("synthesizing receipt for "+event.getId());
// This is really ugly because JS has no way to express an object literal // This is really ugly because JS has no way to express an object literal
@@ -68,6 +72,8 @@ function synthesizeReceipt(userId, event, receiptType) {
* @constructor * @constructor
* @alias module:models/room * @alias module:models/room
* @param {string} roomId Required. The ID of this 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 {Object=} opts Configuration options
* @param {*} opts.storageToken Optional. The token which a data store can use * @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 * to remember the state of the room. What this means is dependent on the store
@@ -102,7 +108,7 @@ function synthesizeReceipt(userId, event, receiptType) {
* @prop {*} storageToken A token which a data store can use to remember * @prop {*} storageToken A token which a data store can use to remember
* the state of the room. * the state of the room.
*/ */
function Room(roomId, opts) { function Room(roomId, client, myUserId, opts) {
opts = opts || {}; opts = opts || {};
opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological";
@@ -115,6 +121,7 @@ function Room(roomId, opts) {
); );
} }
this.myUserId = myUserId;
this.roomId = roomId; this.roomId = roomId;
this.name = roomId; this.name = roomId;
this.tags = { this.tags = {
@@ -171,9 +178,56 @@ function Room(roomId, opts) {
// read by megolm; boolean value - null indicates "use global value" // read by megolm; boolean value - null indicates "use global value"
this._blacklistUnverifiedDevices = null; 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); 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) {
console.warn("Room " + this.room_id + " does not have an m.room.create event");
return '1';
}
const ver = createEvent.getContent()['room_version'];
if (ver === undefined) return '1';
return ver;
};
/**
* Determines whether this room needs to be upgraded to a new version
* @returns {string?} What version the room should be upgraded to, or null if
* the room does not require upgrading at this time.
*/
Room.prototype.shouldUpgradeToVersion = function() {
// This almost certainly won't be the way this actually works - this
// is essentially a stub method.
if (this.getVersion() === LATEST_ROOM_VERSION) return null;
return LATEST_ROOM_VERSION;
};
/**
* 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 * Get the list of pending sent events for this room
* *
@@ -201,6 +255,232 @@ Room.prototype.getLiveTimeline = function() {
return this.getUnfilteredTimelineSet().getLiveTimeline(); return this.getUnfilteredTimelineSet().getLiveTimeline();
}; };
/**
* @param {string} myUserId the user id for the logged in member
* @return {string} the membership type (join | leave | invite) for the logged in user
*/
Room.prototype.getMyMembership = function() {
return this._selfMembership;
};
/**
* If this room is a DM we're invited to,
* try to find out who invited us
* @return {string} user id of the inviter
*/
Room.prototype.getDMInviter = function() {
if (this.myUserId) {
const me = this.getMember(this.myUserId);
if (me) {
return me.getDMInviter();
}
}
if (this._selfMembership === "invite") {
// fall back to summary information
const memberCount = this.getInvitedAndJoinedMemberCount();
if (memberCount == 2 && this._summaryHeroes.length) {
return this._summaryHeroes[0];
}
}
};
/**
* Assuming this room is a DM room, tries to guess with which user.
* @return {string} user id of the other member (could be syncing user)
*/
Room.prototype.guessDMUserId = function() {
const me = this.getMember(this.myUserId);
if (me) {
const inviterId = me.getDMInviter();
if (inviterId) {
return inviterId;
}
}
// remember, we're assuming this room is a DM,
// so returning the first member we find should be fine
const hasHeroes = Array.isArray(this._summaryHeroes) &&
this._summaryHeroes.length;
if (hasHeroes) {
return this._summaryHeroes[0];
}
const members = this.currentState.getMembers();
const anyMember = members.find((m) => m.userId !== this.myUserId);
if (anyMember) {
return anyMember.userId;
}
// it really seems like I'm the only user in the room
// so I probably created a room with just me in it
// and marked it as a DM. Ok then
return this.myUserId;
};
Room.prototype.getAvatarFallbackMember = function() {
const memberCount = this.getInvitedAndJoinedMemberCount();
if (memberCount > 2) {
return;
}
const hasHeroes = Array.isArray(this._summaryHeroes) &&
this._summaryHeroes.length;
if (hasHeroes) {
const availableMember = this._summaryHeroes.map((userId) => {
return this.getMember(userId);
}).find((member) => !!member);
if (availableMember) {
return availableMember;
}
}
const members = this.currentState.getMembers();
// could be different than memberCount
// as this includes left members
if (members.length <= 2) {
const availableMember = members.find((m) => {
return m.userId !== this.myUserId;
});
if (availableMember) {
return availableMember;
}
}
// if all else fails, try falling back to a user,
// and create a one-off member for it
if (hasHeroes) {
const availableUser = this._summaryHeroes.map((userId) => {
return this._client.getUser(userId);
}).find((user) => !!user);
if (availableUser) {
const member = new RoomMember(
this.roomId, availableUser.userId);
member.user = availableUser;
return member;
}
}
};
/**
* Sets the membership this room was received as during sync
* @param {string} membership join | leave | invite
*/
Room.prototype.updateMyMembership = function(membership) {
const prevMembership = this._selfMembership;
this._selfMembership = membership;
if (prevMembership !== membership) {
if (membership === "leave") {
this._cleanupAfterLeaving();
}
this.emit("Room.myMembership", this, membership, prevMembership);
}
};
Room.prototype._loadMembersFromServer = async function() {
const lastSyncToken = this._client.store.getSyncToken();
const queryString = utils.encodeParams({
not_membership: "leave",
at: lastSyncToken,
});
const path = utils.encodeUri("/rooms/$roomId/members?" + queryString,
{$roomId: this.roomId});
const http = this._client._http;
const response = await http.authedRequest(undefined, "GET", path);
return response.chunk;
};
Room.prototype._loadMembers = async function() {
// were the members loaded from the server?
let fromServer = false;
let rawMembersEvents =
await this._client.store.getOutOfBandMembers(this.roomId);
if (rawMembersEvents === null) {
fromServer = true;
rawMembersEvents = await this._loadMembersFromServer();
console.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.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);
console.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) => {
console.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
console.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) => {
console.error(`error after clearing loaded members from ` +
`room ${this.roomId} after leaving`);
console.dir(err);
});
};
/** /**
* Reset the live timeline of all timelineSets, and start new ones. * Reset the live timeline of all timelineSets, and start new ones.
@@ -306,6 +586,26 @@ Room.prototype.setUnreadNotificationCount = function(type, count) {
this._notificationCounts[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. * Whether to send encrypted messages to devices within this room.
* @param {Boolean} value true to blacklist unverified devices, null * @param {Boolean} value true to blacklist unverified devices, null
@@ -430,11 +730,7 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
* @return {RoomMember} The member or <code>null</code>. * @return {RoomMember} The member or <code>null</code>.
*/ */
Room.prototype.getMember = function(userId) { Room.prototype.getMember = function(userId) {
const member = this.currentState.members[userId]; return this.currentState.getMember(userId);
if (!member) {
return null;
}
return member;
}; };
/** /**
@@ -445,6 +741,33 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
return this.getMembersWithMembership("join"); 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. * Get a list of members with given membership state.
* @param {string} membership The membership state. * @param {string} membership The membership state.
@@ -458,10 +781,11 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
/** /**
* Get a list of members we should be encrypting for in this room * Get a list of members we should be encrypting for in this room
* @return {RoomMember[]} A list of members who we should encrypt messages for * @return {Promise<RoomMember[]>} A list of members who
* in this room. * we should encrypt messages for in this room.
*/ */
Room.prototype.getEncryptionTargetMembers = function() { Room.prototype.getEncryptionTargetMembers = async function() {
await this.loadMembersIfNeeded();
let members = this.getMembersWithMembership("join"); let members = this.getMembersWithMembership("join");
if (this.shouldEncryptForInvitedMembers()) { if (this.shouldEncryptForInvitedMembers()) {
members = members.concat(this.getMembersWithMembership("invite")); members = members.concat(this.getMembersWithMembership("invite"));
@@ -675,6 +999,10 @@ Room.prototype.addPendingEvent = function(event, txnId) {
this._txnToEvent[txnId] = event; this._txnToEvent[txnId] = event;
if (this._opts.pendingEventOrdering == "detached") { if (this._opts.pendingEventOrdering == "detached") {
if (this._pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) {
console.warn("Setting event as NOT_SENT due to messages in the same state");
event.status = EventStatus.NOT_SENT;
}
this._pendingEventList.push(event); this._pendingEventList.push(event);
} else { } else {
for (let i = 0; i < this._timelineSets.length; i++) { for (let i = 0; i < this._timelineSets.length; i++) {
@@ -839,7 +1167,7 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) {
this.removeEvent(oldEventId); this.removeEvent(oldEventId);
} }
this.emit("Room.localEchoUpdated", event, this, event.getId(), oldStatus); this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus);
}; };
@@ -931,15 +1259,14 @@ Room.prototype.removeEvent = function(eventId) {
* Recalculate various aspects of the room, including the room name and * Recalculate various aspects of the room, including the room name and
* room summary. Call this any time the room's current state is modified. * room summary. Call this any time the room's current state is modified.
* May fire "Room.name" if the room name is updated. * May fire "Room.name" if the room name is updated.
* @param {string} userId The client's user ID.
* @fires module:client~MatrixClient#event:"Room.name" * @fires module:client~MatrixClient#event:"Room.name"
*/ */
Room.prototype.recalculate = function(userId) { Room.prototype.recalculate = function() {
// set fake stripped state events if this is an invite room so logic remains // set fake stripped state events if this is an invite room so logic remains
// consistent elsewhere. // consistent elsewhere.
const self = this; const self = this;
const membershipEvent = this.currentState.getStateEvents( const membershipEvent = this.currentState.getStateEvents(
"m.room.member", userId, "m.room.member", this.myUserId,
); );
if (membershipEvent && membershipEvent.getContent().membership === "invite") { if (membershipEvent && membershipEvent.getContent().membership === "invite") {
const strippedStateEvents = membershipEvent.event.invite_room_state || []; const strippedStateEvents = membershipEvent.event.invite_room_state || [];
@@ -955,14 +1282,14 @@ Room.prototype.recalculate = function(userId) {
content: strippedEvent.content, content: strippedEvent.content,
event_id: "$fake" + Date.now(), event_id: "$fake" + Date.now(),
room_id: self.roomId, room_id: self.roomId,
user_id: userId, // technically a lie user_id: self.myUserId, // technically a lie
})]); })]);
} }
}); });
} }
const oldName = this.name; const oldName = this.name;
this.name = calculateRoomName(this, userId); this.name = calculateRoomName(this, this.myUserId);
this.summary = new RoomSummary(this.roomId, { this.summary = new RoomSummary(this.roomId, {
title: this.name, title: this.name,
}); });
@@ -1143,7 +1470,7 @@ Room.prototype.addTags = function(event) {
// } // }
// XXX: do we need to deep copy here? // XXX: do we need to deep copy here?
this.tags = event.getContent().tags; this.tags = event.getContent().tags || {};
// XXX: we could do a deep-comparison to see if the tags have really // XXX: we could do a deep-comparison to see if the tags have really
// changed - but do we want to bother? // changed - but do we want to bother?
@@ -1174,6 +1501,17 @@ Room.prototype.getAccountData = function(type) {
return this.accountData[type]; return this.accountData[type];
}; };
/**
* Returns wheter the syncing user has permission to send a message in the room
* @return {boolean} true if the user should be permitted to send
* message events into the room.
*/
Room.prototype.maySendMessage = function() {
return this.getMyMembership() === 'join' &&
this.currentState.maySendEvent('m.room.message', this.myUserId);
};
/** /**
* This is an internal method. Calculates the name of the room from the current * This is an internal method. Calculates the name of the room from the current
* room state. * room state.
@@ -1207,87 +1545,83 @@ function calculateRoomName(room, userId, ignoreRoomNameEvent) {
return 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. // get members that are NOT ourselves and are actually in the room.
const otherMembers = utils.filter(room.currentState.getMembers(), function(m) { let otherNames = null;
return ( if (room._summaryHeroes) {
m.userId !== userId && m.membership !== "leave" && m.membership !== "ban" // 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;
}); });
const allMembers = utils.filter(room.currentState.getMembers(), function(m) {
return (m.membership !== "leave");
});
const myMemberEventArray = utils.filter(room.currentState.getMembers(), function(m) {
return (m.userId == userId);
});
const myMemberEvent = (
(myMemberEventArray.length && myMemberEventArray[0].events) ?
myMemberEventArray[0].events.member.event : undefined
);
// TODO: Localisation
if (myMemberEvent && myMemberEvent.content.membership == "invite") {
if (room.currentState.getMember(myMemberEvent.sender)) {
// extract who invited us to the room
return room.currentState.getMember(
myMemberEvent.sender,
).name;
} else if (allMembers[0].events.member) {
// use the sender field from the invite event, although this only
// gets us the mxid
return myMemberEvent.sender;
} else { } else {
return "Room Invite"; let otherMembers = room.currentState.getMembers().filter((m) => {
} return m.userId !== userId &&
} (m.membership === "invite" || m.membership === "join");
if (otherMembers.length === 0) {
const leftMembers = utils.filter(room.currentState.getMembers(), function(m) {
return m.userId !== userId && m.membership === "leave";
}); });
if (allMembers.length === 1) { // make sure members have stable order
// self-chat, peeked room with 1 participant, otherMembers.sort((a, b) => a.userId.localeCompare(b.userId));
// or inbound invite, or outbound 3PID invite. // only 5 first members, immitate _summaryHeroes
if (allMembers[0].userId === userId) { 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 = const thirdPartyInvites =
room.currentState.getStateEvents("m.room.third_party_invite"); room.currentState.getStateEvents("m.room.third_party_invite");
if (thirdPartyInvites && thirdPartyInvites.length > 0) {
let name = "Inviting " + if (thirdPartyInvites && thirdPartyInvites.length) {
thirdPartyInvites[0].getContent().display_name; const thirdPartyNames = thirdPartyInvites.map((i) => {
if (thirdPartyInvites.length > 1) { return i.getContent().display_name;
if (thirdPartyInvites.length == 2) { });
name += " and " +
thirdPartyInvites[1].getContent().display_name; return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`;
} else {
name += " and " +
thirdPartyInvites.length + " others";
} }
} }
return name; // let's try to figure out who was here before
} else if (leftMembers.length === 1) { let leftNames = otherNames;
// if it was a chat with one person who's now left, it's still // if we didn't have heroes, try finding them in the room state
// notionally a chat with them if(!leftNames.length) {
return leftMembers[0].name; 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 { } else {
return "Empty room"; return "Empty room";
} }
} else { }
return allMembers[0].name;
} function memberNamesToRoomName(names, count = (names.length + 1)) {
} else { const countWithoutMe = count - 1;
// there really isn't anyone in this room... if (!names.length) {
return "Empty room"; return "Empty room";
} } else if (names.length === 1 && countWithoutMe <= 1) {
} else if (otherMembers.length === 1) { return names[0];
return otherMembers[0].name; } else if (names.length === 2 && countWithoutMe <= 2) {
} else if (otherMembers.length === 2) { return `${names[0]} and ${names[1]}`;
return (
otherMembers[0].name + " and " + otherMembers[1].name
);
} else { } else {
return ( const plural = countWithoutMe > 1;
otherMembers[0].name + " and " + (otherMembers.length - 1) + " others" if (plural) {
); return `${names[0]} and ${countWithoutMe} others`;
} else {
return `${names[0]} and 1 other`;
}
} }
} }

View File

@@ -39,6 +39,9 @@ limitations under the License.
* when a user was last active. * when a user was last active.
* @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be
* an approximation and that the user should be seen as active 'now' * 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 {Object} events The events describing this user.
* @prop {MatrixEvent} events.presence The m.presence event for this user. * @prop {MatrixEvent} events.presence The m.presence event for this user.
*/ */
@@ -46,6 +49,7 @@ function User(userId) {
this.userId = userId; this.userId = userId;
this.presence = "offline"; this.presence = "offline";
this.presenceStatusMsg = null; this.presenceStatusMsg = null;
this._unstable_statusMessage = "";
this.displayName = userId; this.displayName = userId;
this.rawDisplayName = userId; this.rawDisplayName = userId;
this.avatarUrl = null; this.avatarUrl = null;
@@ -179,6 +183,16 @@ User.prototype.getLastActiveTs = function() {
return this.lastPresenceTs - this.lastActiveAgo; return this.lastPresenceTs - this.lastActiveAgo;
}; };
/**
* Manually set the user's status message.
* @param {MatrixEvent} event The <code>im.vector.user_status</code> event.
*/
User.prototype._unstable_updateStatusMessage = function(event) {
if (!event.getContent()) this._unstable_statusMessage = "";
else this._unstable_statusMessage = event.getContent()["status"];
this._updateModifiedTime();
};
/** /**
* The User class. * The User class.
*/ */

View File

@@ -14,6 +14,9 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {escapeRegExp, globToRegexp} from "./utils";
/** /**
* @module pushprocessor * @module pushprocessor
*/ */
@@ -26,10 +29,6 @@ const RULEKINDS_IN_ORDER = ['override', 'content', 'room', 'sender', 'underride'
* @param {Object} client The Matrix client object to use * @param {Object} client The Matrix client object to use
*/ */
function PushProcessor(client) { function PushProcessor(client) {
const escapeRegExp = function(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
const cachedGlobToRegex = { const cachedGlobToRegex = {
// $glob: RegExp, // $glob: RegExp,
}; };
@@ -244,22 +243,6 @@ function PushProcessor(client) {
return cachedGlobToRegex[glob]; return cachedGlobToRegex[glob];
}; };
const globToRegexp = function(glob) {
// 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, '.');
pat = pat.replace(/\\\[(!|)(.*)\\]/g, function(match, p1, p2, offset, string) {
const first = p1 && '^' || '';
const second = p2.replace(/\\\-/, '-');
return '[' + first + second + ']';
});
return pat;
};
const valueForDottedKey = function(key, ev) { const valueForDottedKey = function(key, ev) {
const parts = key.split('.'); const parts = key.split('.');
let val; let val;

26
src/randomstring.js Normal file
View File

@@ -0,0 +1,26 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function randomString(len) {
let ret = "";
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < len; ++i) {
ret += chars.charAt(Math.floor(Math.random() * chars.length));
}
return ret;
}

View File

@@ -19,7 +19,7 @@ import Promise from 'bluebird';
import SyncAccumulator from "../sync-accumulator"; import SyncAccumulator from "../sync-accumulator";
import utils from "../utils"; import utils from "../utils";
const VERSION = 1; const VERSION = 3;
function createDatabase(db) { function createDatabase(db) {
// Make user store, clobber based on user ID. (userId property of User objects) // Make user store, clobber based on user ID. (userId property of User objects)
@@ -33,6 +33,20 @@ function createDatabase(db) {
db.createObjectStore("sync", { keyPath: ["clobber"] }); 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. * Helper method to collect results from a Cursor and promiseify it.
* @param {ObjectStore|Index} store The store to perform openCursor on. * @param {ObjectStore|Index} store The store to perform openCursor on.
@@ -63,28 +77,39 @@ function selectQuery(store, keyRange, resultMapper) {
}); });
} }
function promiseifyTxn(txn) { function txnAsPromise(txn) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
txn.oncomplete = function(event) { txn.oncomplete = function(event) {
resolve(event); resolve(event);
}; };
txn.onerror = function(event) { txn.onerror = function(event) {
reject(event); reject(event.target.error);
}; };
}); });
} }
function promiseifyRequest(req) { function reqAsEventPromise(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
req.onsuccess = function(event) { req.onsuccess = function(event) {
resolve(event); resolve(event);
}; };
req.onerror = function(event) { req.onerror = function(event) {
reject(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 * Does the actual reading from and writing to the indexeddb
* *
@@ -104,6 +129,7 @@ const LocalIndexedDBStoreBackend = function LocalIndexedDBStoreBackend(
this.db = null; this.db = null;
this._disconnected = true; this._disconnected = true;
this._syncAccumulator = new SyncAccumulator(); this._syncAccumulator = new SyncAccumulator();
this._isNewlyCreated = false;
}; };
@@ -134,8 +160,15 @@ LocalIndexedDBStoreBackend.prototype = {
`LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`, `LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`,
); );
if (oldVersion < 1) { // The database did not previously exist. if (oldVersion < 1) { // The database did not previously exist.
this._isNewlyCreated = true;
createDatabase(db); createDatabase(db);
} }
if (oldVersion < 2) {
upgradeSchemaV2(db);
}
if (oldVersion < 3) {
upgradeSchemaV3(db);
}
// Expand as needed. // Expand as needed.
}; };
@@ -148,7 +181,7 @@ LocalIndexedDBStoreBackend.prototype = {
console.log( console.log(
`LocalIndexedDBStoreBackend.connect: awaiting connection...`, `LocalIndexedDBStoreBackend.connect: awaiting connection...`,
); );
return promiseifyRequest(req).then((ev) => { return reqAsEventPromise(req).then((ev) => {
console.log( console.log(
`LocalIndexedDBStoreBackend.connect: connected`, `LocalIndexedDBStoreBackend.connect: connected`,
); );
@@ -163,6 +196,10 @@ LocalIndexedDBStoreBackend.prototype = {
return this._init(); 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 * Having connected, load initial data from the database and prepare for use
@@ -187,6 +224,124 @@ LocalIndexedDBStoreBackend.prototype = {
}); });
}, },
/**
* Returns the out-of-band membership events for this room that
* were previously loaded.
* @param {string} roomId
* @returns {Promise<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 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) => {
console.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) {
console.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);
console.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],
);
console.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 * Clear the entire database. This should be used when logging out of a client
* to prevent mixing data between accounts. * to prevent mixing data between accounts.
@@ -284,7 +439,7 @@ LocalIndexedDBStoreBackend.prototype = {
roomsData: roomsData, roomsData: roomsData,
groupsData: groupsData, groupsData: groupsData,
}); // put == UPSERT }); // put == UPSERT
return promiseifyTxn(txn); return txnAsPromise(txn);
}); });
}, },
@@ -301,7 +456,7 @@ LocalIndexedDBStoreBackend.prototype = {
for (let i = 0; i < accountData.length; i++) { for (let i = 0; i < accountData.length; i++) {
store.put(accountData[i]); // put == UPSERT store.put(accountData[i]); // put == UPSERT
} }
return promiseifyTxn(txn); return txnAsPromise(txn);
}); });
}, },
@@ -323,7 +478,7 @@ LocalIndexedDBStoreBackend.prototype = {
event: tuple[1], event: tuple[1],
}); // put == UPSERT }); // put == UPSERT
} }
return promiseifyTxn(txn); return txnAsPromise(txn);
}); });
}, },
@@ -389,6 +544,28 @@ LocalIndexedDBStoreBackend.prototype = {
}); });
}); });
}, },
getClientOptions: function() {
return Promise.resolve().then(() => {
const txn = this.db.transaction(["client_options"], "readonly");
const store = txn.objectStore("client_options");
return selectQuery(store, undefined, (cursor) => {
if (cursor.value && cursor.value && cursor.value.options) {
return cursor.value.options;
}
}).then((results) => results[0]);
});
},
storeClientOptions: async function(options) {
const txn = this.db.transaction(["client_options"], "readwrite");
const store = txn.objectStore("client_options");
store.put({
clobber: "-", // constant key so will always clobber
options: options,
}); // put == UPSERT
await txnAsPromise(txn);
},
}; };
export default LocalIndexedDBStoreBackend; export default LocalIndexedDBStoreBackend;

View File

@@ -65,7 +65,10 @@ RemoteIndexedDBStoreBackend.prototype = {
clearDatabase: function() { clearDatabase: function() {
return this._ensureStarted().then(() => this._doCmd('clearDatabase')); return this._ensureStarted().then(() => this._doCmd('clearDatabase'));
}, },
/** @return {Promise<bool>} 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 * @return {Promise} Resolves with a sync response to restore the
* client state to where it was at the last save, or null if there * client state to where it was at the last save, or null if there
@@ -87,6 +90,40 @@ RemoteIndexedDBStoreBackend.prototype = {
return this._doCmd('syncToDatabase', [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. * Load all user presence events from the database. This is not cached.
@@ -147,7 +184,9 @@ RemoteIndexedDBStoreBackend.prototype = {
if (msg.command == 'cmd_success') { if (msg.command == 'cmd_success') {
def.resolve(msg.result); def.resolve(msg.result);
} else { } else {
def.reject(msg.error); const error = new Error(msg.error.message);
error.name = msg.error.name;
def.reject(error);
} }
} else { } else {
console.warn("Unrecognised message from worker: " + msg); console.warn("Unrecognised message from worker: " + msg);

View File

@@ -67,6 +67,9 @@ class IndexedDBStoreWorker {
case 'connect': case 'connect':
prom = this.backend.connect(); prom = this.backend.connect();
break; break;
case 'isNewlyCreated':
prom = this.backend.isNewlyCreated();
break;
case 'clearDatabase': case 'clearDatabase':
prom = this.backend.clearDatabase().then((result) => { prom = this.backend.clearDatabase().then((result) => {
// This returns special classes which can't be cloned // This returns special classes which can't be cloned
@@ -92,10 +95,25 @@ class IndexedDBStoreWorker {
case 'getNextBatchToken': case 'getNextBatchToken':
prom = this.backend.getNextBatchToken(); prom = this.backend.getNextBatchToken();
break; 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) { if (prom === undefined) {
postMessage({ this.postMessage({
command: 'cmd_fail', command: 'cmd_fail',
seq: msg.seq, seq: msg.seq,
// Can't be an Error because they're not structured cloneable // Can't be an Error because they're not structured cloneable
@@ -117,7 +135,10 @@ class IndexedDBStoreWorker {
command: 'cmd_fail', command: 'cmd_fail',
seq: msg.seq, seq: msg.seq,
// Just send a string because Error objects aren't cloneable // Just send a string because Error objects aren't cloneable
error: "Error running command", error: {
message: err.message,
name: err.name,
},
}); });
}); });
} }

View File

@@ -146,6 +146,11 @@ IndexedDBStore.prototype.getSavedSync = function() {
return this.backend.getSavedSync(); return this.backend.getSavedSync();
}; };
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
IndexedDBStore.prototype.isNewlyCreated = function() {
return this.backend.isNewlyCreated();
};
/** /**
* @return {Promise} If there is a saved sync, the nextBatch token * @return {Promise} If there is a saved sync, the nextBatch token
* for this sync, otherwise null. * for this sync, otherwise null.
@@ -219,4 +224,39 @@ IndexedDBStore.prototype.setSyncData = function(syncData) {
return this.backend.setSyncData(syncData); return this.backend.setSyncData(syncData);
}; };
/**
* 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 = function(roomId) {
return this.backend.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
*/
IndexedDBStore.prototype.setOutOfBandMembers = function(roomId, membershipEvents) {
return this.backend.setOutOfBandMembers(roomId, membershipEvents);
};
IndexedDBStore.prototype.clearOutOfBandMembers = function(roomId) {
return this.backend.clearOutOfBandMembers(roomId);
};
IndexedDBStore.prototype.getClientOptions = function() {
return this.backend.getClientOptions();
};
IndexedDBStore.prototype.storeClientOptions = function(options) {
return this.backend.storeClientOptions(options);
};
module.exports.IndexedDBStore = IndexedDBStore; module.exports.IndexedDBStore = IndexedDBStore;

View File

@@ -52,6 +52,10 @@ module.exports.MatrixInMemoryStore = function MatrixInMemoryStore(opts) {
// type : content // type : content
}; };
this.localStorage = opts.localStorage; this.localStorage = opts.localStorage;
this._oobMembers = {
// roomId: [member events]
};
this._clientOptions = {};
}; };
module.exports.MatrixInMemoryStore.prototype = { module.exports.MatrixInMemoryStore.prototype = {
@@ -64,6 +68,10 @@ module.exports.MatrixInMemoryStore.prototype = {
return this.syncToken; return this.syncToken;
}, },
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
isNewlyCreated: function() {
return Promise.resolve(true);
},
/** /**
* Set the token to stream from. * Set the token to stream from.
@@ -377,4 +385,35 @@ module.exports.MatrixInMemoryStore.prototype = {
}; };
return Promise.resolve(); 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();
},
getClientOptions: function() {
return Promise.resolve(this._clientOptions);
},
storeClientOptions: function(options) {
this._clientOptions = Object.assign({}, options);
return Promise.resolve();
},
}; };

View File

@@ -32,6 +32,11 @@ function StubStore() {
StubStore.prototype = { StubStore.prototype = {
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
isNewlyCreated: function() {
return Promise.resolve(true);
},
/** /**
* Get the sync token. * Get the sync token.
* @return {string} * @return {string}
@@ -264,6 +269,26 @@ StubStore.prototype = {
deleteAllData: function() { deleteAllData: function() {
return Promise.resolve(); return Promise.resolve();
}, },
getOutOfBandMembers: function() {
return Promise.resolve(null);
},
setOutOfBandMembers: function() {
return Promise.resolve();
},
clearOutOfBandMembers: function() {
return Promise.resolve();
},
getClientOptions: function() {
return Promise.resolve();
},
storeClientOptions: function() {
return Promise.resolve();
},
}; };
/** Stub Store class. */ /** Stub Store class. */

View File

@@ -63,6 +63,11 @@ class SyncAccumulator {
// { 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 }, // _accountData: { $event_type: json },
// _unreadNotifications: { ... unread_notifications JSON ... }, // _unreadNotifications: { ... unread_notifications JSON ... },
// _readReceipts: { $user_id: { data: $json, eventId: $event_id }} // _readReceipts: { $user_id: { data: $json, eventId: $event_id }}
@@ -242,6 +247,7 @@ class SyncAccumulator {
_timeline: [], _timeline: [],
_accountData: Object.create(null), _accountData: Object.create(null),
_unreadNotifications: {}, _unreadNotifications: {},
_summary: {},
_readReceipts: {}, _readReceipts: {},
}; };
} }
@@ -258,6 +264,17 @@ class SyncAccumulator {
if (data.unread_notifications) { if (data.unread_notifications) {
currentData._unreadNotifications = 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) { if (data.ephemeral && data.ephemeral.events) {
data.ephemeral.events.forEach((e) => { data.ephemeral.events.forEach((e) => {
@@ -428,6 +445,7 @@ class SyncAccumulator {
prev_batch: null, prev_batch: null,
}, },
unread_notifications: roomData._unreadNotifications, unread_notifications: roomData._unreadNotifications,
summary: roomData._summary,
}; };
// Add account data // Add account data
Object.keys(roomData._accountData).forEach((evType) => { Object.keys(roomData._accountData).forEach((evType) => {

View File

@@ -33,6 +33,8 @@ const utils = require("./utils");
const Filter = require("./filter"); const Filter = require("./filter");
const EventTimeline = require("./models/event-timeline"); const EventTimeline = require("./models/event-timeline");
import {InvalidStoreError} from './errors';
const DEBUG = true; const DEBUG = true;
// /sync requests allow you to set a timeout= but the request may continue // /sync requests allow you to set a timeout= but the request may continue
@@ -93,12 +95,14 @@ function SyncApi(client, opts) {
this._peekRoomId = null; this._peekRoomId = null;
this._currentSyncRequest = null; this._currentSyncRequest = null;
this._syncState = null; this._syncState = null;
this._syncStateData = null; // additional data (eg. error object for failed sync)
this._catchingUp = false; this._catchingUp = false;
this._running = false; this._running = false;
this._keepAliveTimer = null; this._keepAliveTimer = null;
this._connectionReturnedDefer = null; this._connectionReturnedDefer = null;
this._notifEvents = []; // accumulator of sync events in the current sync response this._notifEvents = []; // accumulator of sync events in the current sync response
this._failedSyncCount = 0; // Number of consecutive failed /sync requests 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()) { if (client.getNotifTimelineSet()) {
client.reEmitter.reEmit(client.getNotifTimelineSet(), client.reEmitter.reEmit(client.getNotifTimelineSet(),
@@ -112,7 +116,8 @@ function SyncApi(client, opts) {
*/ */
SyncApi.prototype.createRoom = function(roomId) { SyncApi.prototype.createRoom = function(roomId) {
const client = this.client; const client = this.client;
const room = new Room(roomId, { const room = new Room(roomId, client, client.getUserId(), {
lazyLoadMembers: this.opts.lazyLoadMembers,
pendingEventOrdering: this.opts.pendingEventOrdering, pendingEventOrdering: this.opts.pendingEventOrdering,
timelineSupport: client.timelineSupport, timelineSupport: client.timelineSupport,
}); });
@@ -121,6 +126,7 @@ SyncApi.prototype.createRoom = function(roomId) {
"Room.timelineReset", "Room.timelineReset",
"Room.localEchoUpdated", "Room.localEchoUpdated",
"Room.accountData", "Room.accountData",
"Room.myMembership",
]); ]);
this._registerStateListeners(room); this._registerStateListeners(room);
return room; return room;
@@ -231,7 +237,7 @@ SyncApi.prototype.syncLeftRooms = function() {
self._processRoomEvents(room, stateEvents, timelineEvents); self._processRoomEvents(room, stateEvents, timelineEvents);
room.recalculate(client.credentials.userId); room.recalculate();
client.store.storeRoom(room); client.store.storeRoom(room);
client.emit("Room", room); client.emit("Room", room);
@@ -302,7 +308,7 @@ SyncApi.prototype.peek = function(roomId) {
peekRoom.currentState.setStateEvents(stateEvents); peekRoom.currentState.setStateEvents(stateEvents);
self._resolveInvites(peekRoom); self._resolveInvites(peekRoom);
peekRoom.recalculate(self.client.credentials.userId); peekRoom.recalculate();
// roll backwards to diverge old state. addEventsToTimeline // roll backwards to diverge old state. addEventsToTimeline
// will overwrite the pagination token, so make sure it overwrites // will overwrite the pagination token, so make sure it overwrites
@@ -396,6 +402,18 @@ SyncApi.prototype.getSyncState = function() {
return this._syncState; return this._syncState;
}; };
/**
* Returns the additional data object associated with
* the current sync state, or null if there is no
* such data.
* Sync errors, if available, are put in the 'error' key of
* this object.
* @return {?Object}
*/
SyncApi.prototype.getSyncStateData = function() {
return this._syncStateData;
};
SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise, err) { SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise, err) {
// Wait for the saved sync to complete - we send the pushrules and filter requests // 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 // before the saved sync has finished so they can run in parallel, but only process
@@ -407,6 +425,26 @@ SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise,
await keepaliveProm; 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;
};
/** /**
* Main entry point * Main entry point
*/ */
@@ -429,6 +467,8 @@ SyncApi.prototype.sync = function() {
// 1) We need to get push rules so we can check if events should bing as we get // 1) We need to get push rules so we can check if events should bing as we get
// them from /sync. // them from /sync.
// 2) We need to get/create a filter which we can use for /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() { async function getPushRules() {
try { try {
@@ -443,9 +483,47 @@ SyncApi.prototype.sync = function() {
getPushRules(); getPushRules();
return; return;
} }
getFilter(); // Now get the filter and start syncing checkLazyLoadStatus(); // advance to the next stage
} }
const checkLazyLoadStatus = async () => {
if (this.opts.lazyLoadMembers && client.isGuest()) {
this.opts.lazyLoadMembers = false;
}
if (this.opts.lazyLoadMembers) {
const supported = await client.doesServerSupportLazyLoading();
if (supported) {
this.opts.filter = await client.createFilter(
Filter.LAZY_LOADING_SYNC_FILTER,
);
} else {
console.log("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
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.
console.warn("InvalidStoreError: store is not usable: stopping sync.");
return;
}
if (this.opts.lazyLoadMembers && this.opts.crypto) {
this.opts.crypto.enableLazyLoading();
}
await this.client._storeClientOptions();
getFilter(); // Now get the filter and start syncing
};
async function getFilter() { async function getFilter() {
let filter; let filter;
if (self.opts.filter) { if (self.opts.filter) {
@@ -573,7 +651,12 @@ SyncApi.prototype._syncFromCache = async function(savedSync) {
console.error("Error processing cached sync", e.stack || e); console.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); this._updateSyncState("PREPARED", syncEventData);
}
}; };
/** /**
@@ -763,9 +846,20 @@ SyncApi.prototype._onSyncError = function(err, syncOptions) {
// fails, since long lived HTTP connections will // fails, since long lived HTTP connections will
// go away sometimes and we shouldn't treat this as // go away sometimes and we shouldn't treat this as
// erroneous. We set the state to 'reconnecting' // erroneous. We set the state to 'reconnecting'
// instead, so that clients can onserve this state // instead, so that clients can observe this state
// if they wish. // if they wish.
this._startKeepAlives().then(() => { 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._sync(syncOptions);
}); });
@@ -774,6 +868,7 @@ SyncApi.prototype._onSyncError = function(err, syncOptions) {
this._updateSyncState( this._updateSyncState(
this._failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? this._failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ?
"ERROR" : "RECONNECTING", "ERROR" : "RECONNECTING",
{ error: err },
); );
}; };
@@ -809,6 +904,11 @@ SyncApi.prototype._processSyncResponse = async function(
// state: { events: [] }, // state: { events: [] },
// timeline: { events: [], prev_batch: $token, limited: true }, // timeline: { events: [], prev_batch: $token, limited: true },
// ephemeral: { events: [] }, // ephemeral: { events: [] },
// summary: {
// m.heroes: [ $user_id ],
// m.joined_member_count: $count,
// m.invited_member_count: $count
// },
// account_data: { events: [] }, // account_data: { events: [] },
// unread_notifications: { // unread_notifications: {
// highlight_count: 0, // highlight_count: 0,
@@ -947,9 +1047,11 @@ SyncApi.prototype._processSyncResponse = async function(
const room = inviteObj.room; const room = inviteObj.room;
const stateEvents = const stateEvents =
self._mapSyncEventsFormat(inviteObj.invite_state, room); self._mapSyncEventsFormat(inviteObj.invite_state, room);
room.updateMyMembership("invite");
self._processRoomEvents(room, stateEvents); self._processRoomEvents(room, stateEvents);
if (inviteObj.isBrandNewRoom) { if (inviteObj.isBrandNewRoom) {
room.recalculate(client.credentials.userId); room.recalculate();
client.store.storeRoom(room); client.store.storeRoom(room);
client.emit("Room", room); client.emit("Room", room);
} }
@@ -976,6 +1078,8 @@ SyncApi.prototype._processSyncResponse = async function(
); );
} }
room.updateMyMembership("join");
joinObj.timeline = joinObj.timeline || {}; joinObj.timeline = joinObj.timeline || {};
if (joinObj.isBrandNewRoom) { if (joinObj.isBrandNewRoom) {
@@ -1040,6 +1144,13 @@ SyncApi.prototype._processSyncResponse = async function(
self._processRoomEvents(room, stateEvents, timelineEvents); self._processRoomEvents(room, stateEvents, timelineEvents);
// set summary after processing events,
// because it will trigger a name calculation
// which needs the room state to be up to date
if (joinObj.summary) {
room.setSummary(joinObj.summary);
}
// XXX: should we be adding ephemeralEvents to the timeline? // XXX: should we be adding ephemeralEvents to the timeline?
// It feels like that for symmetry with room.addAccountData() // It feels like that for symmetry with room.addAccountData()
// there should be a room.addEphemeralEvents() or similar. // there should be a room.addEphemeralEvents() or similar.
@@ -1048,7 +1159,7 @@ SyncApi.prototype._processSyncResponse = async function(
// we deliberately don't add accountData to the timeline // we deliberately don't add accountData to the timeline
room.addAccountData(accountDataEvents); room.addAccountData(accountDataEvents);
room.recalculate(client.credentials.userId); room.recalculate();
if (joinObj.isBrandNewRoom) { if (joinObj.isBrandNewRoom) {
client.store.storeRoom(room); client.store.storeRoom(room);
client.emit("Room", room); client.emit("Room", room);
@@ -1061,6 +1172,16 @@ SyncApi.prototype._processSyncResponse = async function(
if (e.isState() && e.getType() == "m.room.encryption" && self.opts.crypto) { if (e.isState() && e.getType() == "m.room.encryption" && self.opts.crypto) {
await self.opts.crypto.onCryptoEvent(e); await self.opts.crypto.onCryptoEvent(e);
} }
if (e.isState() && e.getType() === "im.vector.user_status") {
let user = client.store.getUser(e.getStateKey());
if (user) {
user._unstable_updateStatusMessage(e);
} else {
user = createNewUser(client, e.getStateKey());
user._unstable_updateStatusMessage(e);
client.store.storeUser(user);
}
}
} }
await Promise.mapSeries(stateEvents, processRoomEvent); await Promise.mapSeries(stateEvents, processRoomEvent);
@@ -1083,10 +1204,12 @@ SyncApi.prototype._processSyncResponse = async function(
const accountDataEvents = const accountDataEvents =
self._mapSyncEventsFormat(leaveObj.account_data); self._mapSyncEventsFormat(leaveObj.account_data);
room.updateMyMembership("leave");
self._processRoomEvents(room, stateEvents, timelineEvents); self._processRoomEvents(room, stateEvents, timelineEvents);
room.addAccountData(accountDataEvents); room.addAccountData(accountDataEvents);
room.recalculate(client.credentials.userId); room.recalculate();
if (leaveObj.isBrandNewRoom) { if (leaveObj.isBrandNewRoom) {
client.store.storeRoom(room); client.store.storeRoom(room);
client.emit("Room", room); client.emit("Room", room);
@@ -1175,13 +1298,16 @@ SyncApi.prototype._startKeepAlives = function(delay) {
* *
* On failure, schedules a call back to itself. On success, resolves * On failure, schedules a call back to itself. On success, resolves
* this._connectionReturnedDefer. * this._connectionReturnedDefer.
*
* @param {bool} connDidFail True if a connectivity failure has been detected. Optional.
*/ */
SyncApi.prototype._pokeKeepAlive = function() { SyncApi.prototype._pokeKeepAlive = function(connDidFail) {
if (connDidFail === undefined) connDidFail = false;
const self = this; const self = this;
function success() { function success() {
clearTimeout(self._keepAliveTimer); clearTimeout(self._keepAliveTimer);
if (self._connectionReturnedDefer) { if (self._connectionReturnedDefer) {
self._connectionReturnedDefer.resolve(); self._connectionReturnedDefer.resolve(connDidFail);
self._connectionReturnedDefer = null; self._connectionReturnedDefer = null;
} }
} }
@@ -1198,7 +1324,7 @@ SyncApi.prototype._pokeKeepAlive = function() {
).done(function() { ).done(function() {
success(); success();
}, function(err) { }, function(err) {
if (err.httpStatus == 400) { if (err.httpStatus == 400 || err.httpStatus == 404) {
// treat this as a success because the server probably just doesn't // treat this as a success because the server probably just doesn't
// support /versions: point is, we're getting a response. // support /versions: point is, we're getting a response.
// We wait a short time though, just in case somehow the server // We wait a short time though, just in case somehow the server
@@ -1206,8 +1332,9 @@ SyncApi.prototype._pokeKeepAlive = function() {
// responses fail, this will mean we don't hammer in a loop. // responses fail, this will mean we don't hammer in a loop.
self._keepAliveTimer = setTimeout(success, 2000); self._keepAliveTimer = setTimeout(success, 2000);
} else { } else {
connDidFail = true;
self._keepAliveTimer = setTimeout( self._keepAliveTimer = setTimeout(
self._pokeKeepAlive.bind(self), self._pokeKeepAlive.bind(self, connDidFail),
5000 + Math.floor(Math.random() * 5000), 5000 + Math.floor(Math.random() * 5000),
); );
// A keepalive has failed, so we emit the // A keepalive has failed, so we emit the
@@ -1215,7 +1342,7 @@ SyncApi.prototype._pokeKeepAlive = function() {
// first failure). // first failure).
// Note we do this after setting the timer: // Note we do this after setting the timer:
// this lets the unit tests advance the mock // this lets the unit tests advance the mock
// clock when the get the error. // clock when they get the error.
self._updateSyncState("ERROR", { error: err }); self._updateSyncState("ERROR", { error: err });
} }
}); });
@@ -1376,7 +1503,7 @@ SyncApi.prototype._processRoomEvents = function(room, stateEventList,
// a recalculation (like m.room.name) we won't recalculate until we've // 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 // finished adding all the events, which will cause the notification to have
// the old room name rather than the new one. // the old room name rather than the new one.
room.recalculate(this.client.credentials.userId); room.recalculate();
// If the timeline wasn't empty, we process the state events here: they're // 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 // defined as updates to the state before the start of the timeline, so this
@@ -1451,6 +1578,7 @@ SyncApi.prototype._getGuestFilter = function() {
SyncApi.prototype._updateSyncState = function(newState, data) { SyncApi.prototype._updateSyncState = function(newState, data) {
const old = this._syncState; const old = this._syncState;
this._syncState = newState; this._syncState = newState;
this._syncStateData = data;
this.client.emit("sync", this._syncState, old, data); this.client.emit("sync", this._syncState, old, data);
}; };

View File

@@ -675,3 +675,27 @@ module.exports.removeHiddenChars = function(str) {
return unhomoglyph(str.normalize('NFD').replace(removeHiddenCharsRegex, '')); return unhomoglyph(str.normalize('NFD').replace(removeHiddenCharsRegex, ''));
}; };
const removeHiddenCharsRegex = /[\u200B-\u200D\u0300-\u036f\uFEFF\s]/g; const removeHiddenCharsRegex = /[\u200B-\u200D\u0300-\u036f\uFEFF\s]/g;
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
module.exports.escapeRegExp = escapeRegExp;
module.exports.globToRegexp = function(glob, extended) {
extended = typeof(extended) === 'boolean' ? extended : true;
// From
// https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
// Because micromatch is about 130KB with dependencies,
// and minimatch is not much better.
let pat = escapeRegExp(glob);
pat = pat.replace(/\\\*/g, '.*');
pat = pat.replace(/\?/g, '.');
if (extended) {
pat = pat.replace(/\\\[(!|)(.*)\\]/g, function(match, p1, p2, offset, string) {
const first = p1 && '^' || '';
const second = p2.replace(/\\\-/, '-');
return '[' + first + second + ']';
});
}
return pat;
};

View File

@@ -5,7 +5,7 @@ set -ex
npm run lint npm run lint
# install Olm so that we can run the crypto tests. # install Olm so that we can run the crypto tests.
npm install https://matrix.org/packages/npm/olm/olm-2.2.2.tgz npm install https://matrix.org/packages/npm/olm/olm-3.0.0.tgz
npm run test npm run test