You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-11-25 05:23:13 +03:00
Merge branch 'develop' into t3chguy/unhomoglyph
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- node # Latest stable version of nodejs.
|
||||
- "10.11.0"
|
||||
script:
|
||||
- ./travis.sh
|
||||
|
||||
326
CHANGELOG.md
326
CHANGELOG.md
@@ -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)
|
||||
==================================================================================================
|
||||
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.10.6-rc.1...v0.10.6)
|
||||
|
||||
56
README.md
56
README.md
@@ -30,9 +30,61 @@ In Node.js
|
||||
console.log("Public Rooms: %s", JSON.stringify(data));
|
||||
});
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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?
|
||||
----------------------
|
||||
@@ -267,13 +319,13 @@ To provide the Olm library in a browser 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
|
||||
https://matrix.org/packages/npm/olm/)
|
||||
* ``global.Olm = require('olm');`` *before* loading ``matrix-js-sdk``.
|
||||
|
||||
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)
|
||||
or ``--save`` (if it doesn't) to do so.
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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
|
||||
// indexeddb disabled.
|
||||
|
||||
@@ -202,9 +202,9 @@ function printRoomList() {
|
||||
dateStr = new Date(msg.getTs()).toISOString().replace(
|
||||
/T/, ' ').replace(/\..+/, '');
|
||||
}
|
||||
var me = roomList[i].getMember(myUserId);
|
||||
if (me) {
|
||||
fmt = fmts[me.membership];
|
||||
var myMembership = roomList[i].getMyMembership();
|
||||
if (myMembership) {
|
||||
fmt = fmts[myMembership];
|
||||
}
|
||||
var roomName = fixWidth(roomList[i].name, 25);
|
||||
print(
|
||||
|
||||
@@ -5,7 +5,7 @@ set -x
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||
|
||||
nvm use 6 || exit $?
|
||||
nvm use 10 || exit $?
|
||||
npm install || exit $?
|
||||
|
||||
RC=0
|
||||
|
||||
7124
package-lock.json
generated
Normal file
7124
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "0.10.6",
|
||||
"version": "0.14.2",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -10,7 +10,9 @@
|
||||
"test": "npm run test:build && npm run test:run",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -19,6 +21,7 @@
|
||||
"prepublish": "npm run clean && npm run build && git rev-parse HEAD > git-revision.txt"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/matrix-org/matrix-js-sdk"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -53,17 +56,20 @@
|
||||
"babel-runtime": "^6.26.0",
|
||||
"bluebird": "^3.5.0",
|
||||
"browser-request": "^0.3.3",
|
||||
"bs58": "^4.0.1",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"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-runtime": "^6.23.0",
|
||||
"babel-preset-es2015": "^6.18.0",
|
||||
"browserify": "^14.0.0",
|
||||
"browserify": "^16.2.3",
|
||||
"browserify-shim": "^3.8.13",
|
||||
"eslint": "^3.13.1",
|
||||
"eslint-config-google": "^0.7.1",
|
||||
@@ -72,14 +78,14 @@
|
||||
"istanbul": "^0.4.5",
|
||||
"jsdoc": "^3.5.5",
|
||||
"lolex": "^1.5.2",
|
||||
"matrix-mock-request": "^1.2.0",
|
||||
"mocha": "^3.2.0",
|
||||
"mocha-jenkins-reporter": "^0.3.6",
|
||||
"matrix-mock-request": "^1.2.2",
|
||||
"mocha": "^5.2.0",
|
||||
"mocha-jenkins-reporter": "^0.4.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"source-map-support": "^0.4.11",
|
||||
"sourceify": "^0.1.0",
|
||||
"uglify-js": "^2.8.26",
|
||||
"watchify": "^3.2.1"
|
||||
"watchify": "^3.11.0"
|
||||
},
|
||||
"browserify": {
|
||||
"transform": [
|
||||
|
||||
28
release.sh
28
release.sh
@@ -11,7 +11,17 @@
|
||||
set -e
|
||||
|
||||
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"
|
||||
|
||||
@@ -45,7 +55,8 @@ fi
|
||||
skip_changelog=
|
||||
skip_jsdoc=
|
||||
changelog_file="CHANGELOG.md"
|
||||
while getopts hc:xz f; do
|
||||
expected_npm_user="matrixdotorg"
|
||||
while getopts hc:u:xz f; do
|
||||
case $f in
|
||||
h)
|
||||
help
|
||||
@@ -60,6 +71,9 @@ while getopts hc:xz f; do
|
||||
z)
|
||||
skip_jsdoc=1
|
||||
;;
|
||||
u)
|
||||
expected_npm_user="$OPTARG"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
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)
|
||||
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
|
||||
release="${1#v}"
|
||||
tag="v${release}"
|
||||
@@ -245,7 +265,7 @@ release_text=`mktemp`
|
||||
echo "$tag" > "${release_text}"
|
||||
echo >> "${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
|
||||
rm -rf "$builddir"
|
||||
@@ -281,7 +301,7 @@ fi
|
||||
echo "updating master branch"
|
||||
git checkout master
|
||||
git pull
|
||||
git merge --ff-only "$rel_branch"
|
||||
git merge "$rel_branch"
|
||||
|
||||
# push master and docs (if generated) to github
|
||||
git push origin master
|
||||
|
||||
@@ -102,9 +102,11 @@ TestClient.prototype.start = function() {
|
||||
|
||||
/**
|
||||
* stop the client
|
||||
* @return {Promise} Resolves once the mock http backend has finished all pending flushes
|
||||
*/
|
||||
TestClient.prototype.stop = function() {
|
||||
this.client.stopClient();
|
||||
return this.httpBackend.stop();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,7 +97,7 @@ describe("DeviceList management:", function() {
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
aliceTestClient.stop();
|
||||
return aliceTestClient.stop();
|
||||
});
|
||||
|
||||
it("Alice shouldn't do a second /query for non-e2e-capable devices", function() {
|
||||
|
||||
@@ -410,10 +410,10 @@ describe("MatrixClient crypto", function() {
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
aliTestClient.stop();
|
||||
aliTestClient.httpBackend.verifyNoOutstandingExpectation();
|
||||
bobTestClient.stop();
|
||||
bobTestClient.httpBackend.verifyNoOutstandingExpectation();
|
||||
|
||||
return Promise.all([aliTestClient.stop(), bobTestClient.stop()]);
|
||||
});
|
||||
|
||||
it("Bob uploads device keys", function() {
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("MatrixClient events", function() {
|
||||
afterEach(function() {
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
client.stopClient();
|
||||
return httpBackend.stop();
|
||||
});
|
||||
|
||||
describe("emissions", function() {
|
||||
|
||||
@@ -111,6 +111,7 @@ describe("getEventTimeline support", function() {
|
||||
if (client) {
|
||||
client.stopClient();
|
||||
}
|
||||
return httpBackend.stop();
|
||||
});
|
||||
|
||||
it("timeline support must be enabled to work", function(done) {
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("MatrixClient", function() {
|
||||
|
||||
afterEach(function() {
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
return httpBackend.stop();
|
||||
});
|
||||
|
||||
describe("uploadContent", function() {
|
||||
@@ -159,7 +160,7 @@ describe("MatrixClient", function() {
|
||||
describe("joinRoom", function() {
|
||||
it("should no-op if you've already joined a room", function() {
|
||||
const roomId = "!foo:bar";
|
||||
const room = new Room(roomId);
|
||||
const room = new Room(roomId, userId);
|
||||
room.addLiveEvents([
|
||||
utils.mkMembership({
|
||||
user: userId, room: roomId, mship: "join", event: true,
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("MatrixClient opts", function() {
|
||||
|
||||
afterEach(function() {
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
return httpBackend.stop();
|
||||
});
|
||||
|
||||
describe("without opts.store", function() {
|
||||
@@ -94,7 +95,7 @@ describe("MatrixClient opts", function() {
|
||||
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
|
||||
"m.room.message", "m.room.name", "m.room.member", "m.room.member",
|
||||
"m.room.create",
|
||||
@@ -110,20 +111,16 @@ describe("MatrixClient opts", function() {
|
||||
httpBackend.when("GET", "/pushrules").respond(200, {});
|
||||
httpBackend.when("POST", "/filter").respond(200, { filter_id: "foo" });
|
||||
httpBackend.when("GET", "/sync").respond(200, syncData);
|
||||
client.startClient();
|
||||
httpBackend.flush("/pushrules", 1).then(function() {
|
||||
return httpBackend.flush("/filter", 1);
|
||||
}).then(function() {
|
||||
return Promise.all([
|
||||
httpBackend.flush("/sync", 1),
|
||||
utils.syncPromise(client),
|
||||
]);
|
||||
}).done(function() {
|
||||
expect(expectedEventTypes.length).toEqual(
|
||||
0, "Expected to see event types: " + expectedEventTypes,
|
||||
);
|
||||
done();
|
||||
});
|
||||
await client.startClient();
|
||||
await httpBackend.flush("/pushrules", 1);
|
||||
await httpBackend.flush("/filter", 1);
|
||||
await Promise.all([
|
||||
httpBackend.flush("/sync", 1),
|
||||
utils.syncPromise(client),
|
||||
]);
|
||||
expect(expectedEventTypes.length).toEqual(
|
||||
0, "Expected to see event types: " + expectedEventTypes,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ describe("MatrixClient retrying", function() {
|
||||
|
||||
afterEach(function() {
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
return httpBackend.stop();
|
||||
});
|
||||
|
||||
xit("should retry according to MatrixScheduler.retryFn", function() {
|
||||
|
||||
@@ -130,6 +130,7 @@ describe("MatrixClient room timelines", function() {
|
||||
afterEach(function() {
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
client.stopClient();
|
||||
return httpBackend.stop();
|
||||
});
|
||||
|
||||
describe("local echo events", function() {
|
||||
|
||||
@@ -38,6 +38,7 @@ describe("MatrixClient syncing", function() {
|
||||
afterEach(function() {
|
||||
httpBackend.verifyNoOutstandingExpectation();
|
||||
client.stopClient();
|
||||
return httpBackend.stop();
|
||||
});
|
||||
|
||||
describe("startClient", function() {
|
||||
|
||||
@@ -296,7 +296,7 @@ describe("megolm", function() {
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
aliceTestClient.stop();
|
||||
return aliceTestClient.stop();
|
||||
});
|
||||
|
||||
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([
|
||||
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.
|
||||
aliceTestClient.httpBackend.flushAllExpected({
|
||||
|
||||
670
spec/unit/autodiscovery.spec.js
Normal file
670
spec/unit/autodiscovery.spec.js
Normal 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);
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,122 @@
|
||||
|
||||
"use strict";
|
||||
import 'source-map-support/register';
|
||||
|
||||
const sdk = require("../..");
|
||||
let Crypto;
|
||||
if (sdk.CRYPTO_ENABLED) {
|
||||
Crypto = require("../../lib/crypto");
|
||||
}
|
||||
import '../olm-loader';
|
||||
|
||||
import Crypto from '../../lib/crypto';
|
||||
import expect from 'expect';
|
||||
|
||||
import WebStorageSessionStore from '../../lib/store/session/webstorage';
|
||||
import MemoryCryptoStore from '../../lib/crypto/store/memory-crypto-store.js';
|
||||
import MockStorageApi from '../MockStorageApi';
|
||||
|
||||
const EventEmitter = require("events").EventEmitter;
|
||||
|
||||
const sdk = require("../..");
|
||||
|
||||
const Olm = global.Olm;
|
||||
|
||||
describe("Crypto", function() {
|
||||
if (!sdk.CRYPTO_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
beforeEach(function(done) {
|
||||
Olm.init().then(done);
|
||||
});
|
||||
|
||||
it("Crypto exposes the correct olm library version", function() {
|
||||
expect(Crypto.getOlmVersion()[0]).toEqual(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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,16 +59,25 @@ describe('DeviceList', function() {
|
||||
let downloadSpy;
|
||||
let sessionStore;
|
||||
let cryptoStore;
|
||||
let deviceLists = [];
|
||||
|
||||
beforeEach(function() {
|
||||
testUtils.beforeEach(this); // eslint-disable-line no-invalid-this
|
||||
|
||||
deviceLists = [];
|
||||
|
||||
downloadSpy = expect.createSpy();
|
||||
const mockStorage = new MockStorageApi();
|
||||
sessionStore = new WebStorageSessionStore(mockStorage);
|
||||
cryptoStore = new MemoryCryptoStore();
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
for (const dl of deviceLists) {
|
||||
dl.stop();
|
||||
}
|
||||
});
|
||||
|
||||
function createTestDeviceList() {
|
||||
const baseApis = {
|
||||
downloadKeysForUsers: downloadSpy,
|
||||
@@ -76,7 +85,9 @@ describe('DeviceList', function() {
|
||||
const mockOlm = {
|
||||
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() {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
try {
|
||||
global.Olm = require('olm');
|
||||
} catch (e) {
|
||||
console.warn("unable to run megolm tests: libolm not available");
|
||||
}
|
||||
import '../../../olm-loader';
|
||||
|
||||
import expect from 'expect';
|
||||
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 MockStorageApi from '../../../MockStorageApi';
|
||||
import testUtils from '../../../test-utils';
|
||||
|
||||
// Crypto and OlmDevice won't import unless we have global.Olm
|
||||
let OlmDevice;
|
||||
let Crypto;
|
||||
if (global.Olm) {
|
||||
OlmDevice = require('../../../../lib/crypto/OlmDevice');
|
||||
Crypto = require('../../../../lib/crypto');
|
||||
}
|
||||
import OlmDevice from '../../../../lib/crypto/OlmDevice';
|
||||
import Crypto from '../../../../lib/crypto';
|
||||
|
||||
const MatrixEvent = sdk.MatrixEvent;
|
||||
const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2'];
|
||||
const MegolmEncryption = algorithms.ENCRYPTION_CLASSES['m.megolm.v1.aes-sha2'];
|
||||
|
||||
const ROOM_ID = '!ROOM:ID';
|
||||
|
||||
const Olm = global.Olm;
|
||||
|
||||
describe("MegolmDecryption", function() {
|
||||
if (!global.Olm) {
|
||||
console.warn('Not running megolm unit tests: libolm not present');
|
||||
@@ -38,9 +31,11 @@ describe("MegolmDecryption", function() {
|
||||
let mockCrypto;
|
||||
let mockBaseApis;
|
||||
|
||||
beforeEach(function() {
|
||||
beforeEach(async function() {
|
||||
testUtils.beforeEach(this); // eslint-disable-line no-invalid-this
|
||||
|
||||
await Olm.init();
|
||||
|
||||
mockCrypto = testUtils.mock(Crypto, 'Crypto');
|
||||
mockBaseApis = {};
|
||||
|
||||
@@ -69,7 +64,7 @@ describe("MegolmDecryption", function() {
|
||||
|
||||
describe('receives some keys:', function() {
|
||||
let groupSession;
|
||||
beforeEach(function() {
|
||||
beforeEach(async function() {
|
||||
groupSession = new global.Olm.OutboundGroupSession();
|
||||
groupSession.create();
|
||||
|
||||
@@ -98,7 +93,7 @@ describe("MegolmDecryption", function() {
|
||||
},
|
||||
};
|
||||
|
||||
return event.attemptDecryption(mockCrypto).then(() => {
|
||||
await event.attemptDecryption(mockCrypto).then(() => {
|
||||
megolmDecryption.onRoomKeyEvent(event);
|
||||
});
|
||||
});
|
||||
@@ -266,5 +261,92 @@ describe("MegolmDecryption", function() {
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
86
spec/unit/crypto/algorithms/olm.spec.js
Normal file
86
spec/unit/crypto/algorithms/olm.spec.js
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
467
spec/unit/crypto/backup.spec.js
Normal file
467
spec/unit/crypto/backup.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -139,6 +139,9 @@ describe("MatrixClient", function() {
|
||||
store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null));
|
||||
store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null));
|
||||
store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null));
|
||||
store.getClientOptions = expect.createSpy().andReturn(Promise.resolve(null));
|
||||
store.storeClientOptions = expect.createSpy().andReturn(Promise.resolve(null));
|
||||
store.isNewlyCreated = expect.createSpy().andReturn(Promise.resolve(true));
|
||||
client = new MatrixClient({
|
||||
baseUrl: "https://my.home.server",
|
||||
idBaseUrl: identityServerUrl,
|
||||
@@ -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.push(PUSH_RULES_RESPONSE);
|
||||
httpLookups.push(SYNC_RESPONSE);
|
||||
@@ -191,15 +194,19 @@ describe("MatrixClient", function() {
|
||||
const filter = new sdk.Filter(0, filterId);
|
||||
filter.setDefinition({"room": {"timeline": {"limit": 8}}});
|
||||
store.getFilter.andReturn(filter);
|
||||
client.startClient();
|
||||
|
||||
client.on("sync", function syncListener(state) {
|
||||
if (state === "SYNCING") {
|
||||
expect(httpLookups.length).toEqual(0);
|
||||
client.removeListener("sync", syncListener);
|
||||
done();
|
||||
}
|
||||
const syncPromise = new Promise((resolve, reject) => {
|
||||
client.on("sync", function syncListener(state) {
|
||||
if (state === "SYNCING") {
|
||||
expect(httpLookups.length).toEqual(0);
|
||||
client.removeListener("sync", syncListener);
|
||||
resolve();
|
||||
} else if (state === "ERROR") {
|
||||
reject(new Error("sync error"));
|
||||
}
|
||||
});
|
||||
});
|
||||
await client.startClient();
|
||||
await syncPromise;
|
||||
});
|
||||
|
||||
describe("getSyncState", function() {
|
||||
@@ -207,15 +214,18 @@ describe("MatrixClient", function() {
|
||||
expect(client.getSyncState()).toBe(null);
|
||||
});
|
||||
|
||||
it("should return the same sync state as emitted sync events", function(done) {
|
||||
client.on("sync", function syncListener(state) {
|
||||
expect(state).toEqual(client.getSyncState());
|
||||
if (state === "SYNCING") {
|
||||
client.removeListener("sync", syncListener);
|
||||
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) {
|
||||
expect(state).toEqual(client.getSyncState());
|
||||
if (state === "SYNCING") {
|
||||
client.removeListener("sync", syncListener);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
client.startClient();
|
||||
await client.startClient();
|
||||
await syncingPromise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,8 +268,8 @@ describe("MatrixClient", function() {
|
||||
});
|
||||
|
||||
describe("retryImmediately", function() {
|
||||
it("should return false if there is no request waiting", function() {
|
||||
client.startClient();
|
||||
it("should return false if there is no request waiting", async function() {
|
||||
await client.startClient();
|
||||
expect(client.retryImmediately()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -380,7 +390,7 @@ describe("MatrixClient", function() {
|
||||
client.startClient();
|
||||
});
|
||||
|
||||
it("should transition ERROR -> PREPARED after /sync if prev failed",
|
||||
it("should transition ERROR -> CATCHUP after /sync if prev failed",
|
||||
function(done) {
|
||||
const expectedStates = [];
|
||||
acceptKeepalives = false;
|
||||
@@ -403,7 +413,7 @@ describe("MatrixClient", function() {
|
||||
|
||||
expectedStates.push(["RECONNECTING", null]);
|
||||
expectedStates.push(["ERROR", "RECONNECTING"]);
|
||||
expectedStates.push(["PREPARED", "ERROR"]);
|
||||
expectedStates.push(["CATCHUP", "ERROR"]);
|
||||
client.on("sync", syncChecker(expectedStates, done));
|
||||
client.startClient();
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
const joinEvent = utils.mkMembership({
|
||||
event: true,
|
||||
|
||||
@@ -11,6 +11,9 @@ describe("RoomState", function() {
|
||||
const roomId = "!foo:bar";
|
||||
const userA = "@alice:bar";
|
||||
const userB = "@bob:bar";
|
||||
const userC = "@cleo:bar";
|
||||
const userLazy = "@lazy:bar";
|
||||
|
||||
let state;
|
||||
|
||||
beforeEach(function() {
|
||||
@@ -78,8 +81,8 @@ describe("RoomState", function() {
|
||||
});
|
||||
|
||||
describe("getSentinelMember", function() {
|
||||
it("should return null if there is no member", function() {
|
||||
expect(state.getSentinelMember("@no-one:here")).toEqual(null);
|
||||
it("should return a member with the user id as name", function() {
|
||||
expect(state.getSentinelMember("@no-one:here").name).toEqual("@no-one:here");
|
||||
});
|
||||
|
||||
it("should return a member which doesn't change when the state is updated",
|
||||
@@ -162,6 +165,7 @@ describe("RoomState", function() {
|
||||
];
|
||||
let emitCount = 0;
|
||||
state.on("RoomState.newMember", function(ev, st, mem) {
|
||||
expect(state.getMember(mem.userId)).toEqual(mem);
|
||||
expect(mem.userId).toEqual(memberEvents[emitCount].getSender());
|
||||
expect(mem.membership).toBeFalsy(); // not defined yet
|
||||
emitCount += 1;
|
||||
@@ -222,7 +226,6 @@ describe("RoomState", function() {
|
||||
|
||||
it("should call setPowerLevelEvent on a new RoomMember if power levels exist",
|
||||
function() {
|
||||
const userC = "@cleo:bar";
|
||||
const memberEvent = utils.mkMembership({
|
||||
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() {
|
||||
it("should call setTypingEvent on each RoomMember", function() {
|
||||
const typingEvent = utils.mkEvent({
|
||||
@@ -284,13 +395,6 @@ describe("RoomState", 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",
|
||||
function() {
|
||||
expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true);
|
||||
@@ -366,15 +470,117 @@ describe("RoomState", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("maySendEvent", function() {
|
||||
it("should say non-joined members may not send events",
|
||||
function() {
|
||||
expect(state.maySendEvent(
|
||||
'm.room.message', "@nobody:nowhere",
|
||||
)).toEqual(false);
|
||||
expect(state.maySendMessage("@nobody:nowhere")).toEqual(false);
|
||||
describe("getJoinedMemberCount", function() {
|
||||
beforeEach(() => {
|
||||
state = new RoomState(roomId);
|
||||
});
|
||||
|
||||
it("should update after adding joined member", function() {
|
||||
state.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "join",
|
||||
user: userA, room: roomId}),
|
||||
]);
|
||||
expect(state.getJoinedMemberCount()).toEqual(1);
|
||||
state.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "join",
|
||||
user: userC, room: roomId}),
|
||||
]);
|
||||
expect(state.getJoinedMemberCount()).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInvitedMemberCount", function() {
|
||||
beforeEach(() => {
|
||||
state = new RoomState(roomId);
|
||||
});
|
||||
|
||||
it("should update after adding invited member", function() {
|
||||
state.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "invite",
|
||||
user: userA, room: roomId}),
|
||||
]);
|
||||
expect(state.getInvitedMemberCount()).toEqual(1);
|
||||
state.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "invite",
|
||||
user: userC, room: roomId}),
|
||||
]);
|
||||
expect(state.getInvitedMemberCount()).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setJoinedMemberCount", function() {
|
||||
beforeEach(() => {
|
||||
state = new RoomState(roomId);
|
||||
});
|
||||
|
||||
it("should, once used, override counting members from state", function() {
|
||||
state.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "join",
|
||||
user: userA, room: roomId}),
|
||||
]);
|
||||
expect(state.getJoinedMemberCount()).toEqual(1);
|
||||
state.setJoinedMemberCount(100);
|
||||
expect(state.getJoinedMemberCount()).toEqual(100);
|
||||
state.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "join",
|
||||
user: userC, room: roomId}),
|
||||
]);
|
||||
expect(state.getJoinedMemberCount()).toEqual(100);
|
||||
});
|
||||
|
||||
it("should, once used, override counting members from state, " +
|
||||
"also after clone", function() {
|
||||
state.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "join",
|
||||
user: userA, room: roomId}),
|
||||
]);
|
||||
state.setJoinedMemberCount(100);
|
||||
const copy = state.clone();
|
||||
copy.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "join",
|
||||
user: userC, room: roomId}),
|
||||
]);
|
||||
expect(state.getJoinedMemberCount()).toEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setInvitedMemberCount", function() {
|
||||
beforeEach(() => {
|
||||
state = new RoomState(roomId);
|
||||
});
|
||||
|
||||
it("should, once used, override counting members from state", function() {
|
||||
state.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "invite",
|
||||
user: userB, room: roomId}),
|
||||
]);
|
||||
expect(state.getInvitedMemberCount()).toEqual(1);
|
||||
state.setInvitedMemberCount(100);
|
||||
expect(state.getInvitedMemberCount()).toEqual(100);
|
||||
state.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "invite",
|
||||
user: userC, room: roomId}),
|
||||
]);
|
||||
expect(state.getInvitedMemberCount()).toEqual(100);
|
||||
});
|
||||
|
||||
it("should, once used, override counting members from state, " +
|
||||
"also after clone", function() {
|
||||
state.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "invite",
|
||||
user: userB, room: roomId}),
|
||||
]);
|
||||
state.setInvitedMemberCount(100);
|
||||
const copy = state.clone();
|
||||
copy.setStateEvents([
|
||||
utils.mkMembership({event: true, mship: "invite",
|
||||
user: userC, room: roomId}),
|
||||
]);
|
||||
expect(state.getInvitedMemberCount()).toEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maySendEvent", function() {
|
||||
it("should say any member may send events with no power level event",
|
||||
function() {
|
||||
expect(state.maySendEvent('m.room.message', userA)).toEqual(true);
|
||||
|
||||
@@ -67,13 +67,14 @@ describe("Room", function() {
|
||||
|
||||
describe("getMember", function() {
|
||||
beforeEach(function() {
|
||||
// clobber members property with test data
|
||||
room.currentState.members = {
|
||||
"@alice:bar": {
|
||||
userId: userA,
|
||||
roomId: roomId,
|
||||
},
|
||||
};
|
||||
room.currentState.getMember.andCall(function(userId) {
|
||||
return {
|
||||
"@alice:bar": {
|
||||
userId: userA,
|
||||
roomId: roomId,
|
||||
},
|
||||
}[userId];
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null if the member isn't in current state", function() {
|
||||
@@ -386,7 +387,7 @@ describe("Room", function() {
|
||||
let events = null;
|
||||
|
||||
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
|
||||
// doesn't work because they get frozen)
|
||||
events = [
|
||||
@@ -468,7 +469,7 @@ describe("Room", function() {
|
||||
|
||||
describe("compareEventOrdering", function() {
|
||||
beforeEach(function() {
|
||||
room = new Room(roomId, {timelineSupport: true});
|
||||
room = new Room(roomId, null, null, {timelineSupport: true});
|
||||
});
|
||||
|
||||
const events = [
|
||||
@@ -570,72 +571,75 @@ describe("Room", function() {
|
||||
describe("hasMembershipState", function() {
|
||||
it("should return true for a matching userId and membership",
|
||||
function() {
|
||||
room.currentState.members = {
|
||||
"@alice:bar": { userId: "@alice:bar", membership: "join" },
|
||||
"@bob:bar": { userId: "@bob:bar", membership: "invite" },
|
||||
};
|
||||
room.currentState.getMember.andCall(function(userId) {
|
||||
return {
|
||||
"@alice:bar": { userId: "@alice:bar", membership: "join" },
|
||||
"@bob:bar": { userId: "@bob:bar", membership: "invite" },
|
||||
}[userId];
|
||||
});
|
||||
expect(room.hasMembershipState("@bob:bar", "invite")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if match membership but no match userId",
|
||||
function() {
|
||||
room.currentState.members = {
|
||||
"@alice:bar": { userId: "@alice:bar", membership: "join" },
|
||||
};
|
||||
room.currentState.getMember.andCall(function(userId) {
|
||||
return {
|
||||
"@alice:bar": { userId: "@alice:bar", membership: "join" },
|
||||
}[userId];
|
||||
});
|
||||
expect(room.hasMembershipState("@bob:bar", "join")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if match userId but no match membership",
|
||||
function() {
|
||||
room.currentState.members = {
|
||||
"@alice:bar": { userId: "@alice:bar", membership: "join" },
|
||||
};
|
||||
room.currentState.getMember.andCall(function(userId) {
|
||||
return {
|
||||
"@alice:bar": { userId: "@alice:bar", membership: "join" },
|
||||
}[userId];
|
||||
});
|
||||
expect(room.hasMembershipState("@alice:bar", "ban")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if no match membership or userId",
|
||||
function() {
|
||||
room.currentState.members = {
|
||||
"@alice:bar": { userId: "@alice:bar", membership: "join" },
|
||||
};
|
||||
room.currentState.getMember.andCall(function(userId) {
|
||||
return {
|
||||
"@alice:bar": { userId: "@alice:bar", membership: "join" },
|
||||
}[userId];
|
||||
});
|
||||
expect(room.hasMembershipState("@bob:bar", "invite")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if no members exist",
|
||||
function() {
|
||||
room.currentState.members = {};
|
||||
expect(room.hasMembershipState("@foo:bar", "join")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recalculate", function() {
|
||||
let stateLookup = {
|
||||
// event.type + "$" event.state_key : MatrixEvent
|
||||
};
|
||||
|
||||
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: {
|
||||
join_rule: rule,
|
||||
}, event: true,
|
||||
});
|
||||
})]);
|
||||
};
|
||||
const setAliases = function(aliases, stateKey) {
|
||||
if (!stateKey) {
|
||||
stateKey = "flibble";
|
||||
}
|
||||
stateLookup["m.room.aliases$" + stateKey] = utils.mkEvent({
|
||||
room.addLiveEvents([utils.mkEvent({
|
||||
type: "m.room.aliases", room: roomId, skey: stateKey, content: {
|
||||
aliases: aliases,
|
||||
}, event: true,
|
||||
});
|
||||
})]);
|
||||
};
|
||||
const setRoomName = function(name) {
|
||||
stateLookup["m.room.name$"] = utils.mkEvent({
|
||||
room.addLiveEvents([utils.mkEvent({
|
||||
type: "m.room.name", room: roomId, user: userA, content: {
|
||||
name: name,
|
||||
}, event: true,
|
||||
});
|
||||
})]);
|
||||
};
|
||||
const addMember = function(userId, state, opts) {
|
||||
if (!state) {
|
||||
@@ -647,56 +651,14 @@ describe("Room", function() {
|
||||
opts.user = opts.user || userId;
|
||||
opts.skey = userId;
|
||||
opts.event = true;
|
||||
stateLookup["m.room.member$" + userId] = utils.mkMembership(opts);
|
||||
const event = utils.mkMembership(opts);
|
||||
room.addLiveEvents([event]);
|
||||
return event;
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
stateLookup = {};
|
||||
room.currentState.getStateEvents.andCall(function(type, key) {
|
||||
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 },
|
||||
};
|
||||
});
|
||||
// no mocking
|
||||
room = new Room(roomId, null, userA);
|
||||
});
|
||||
|
||||
describe("Room.recalculate => Stripped State Events", function() {
|
||||
@@ -704,8 +666,8 @@ describe("Room", function() {
|
||||
"room is an invite room", function() {
|
||||
const roomName = "flibble";
|
||||
|
||||
addMember(userA, "invite");
|
||||
stateLookup["m.room.member$" + userA].event.invite_room_state = [
|
||||
const event = addMember(userA, "invite");
|
||||
event.event.invite_room_state = [
|
||||
{
|
||||
type: "m.room.name",
|
||||
state_key: "",
|
||||
@@ -715,30 +677,108 @@ describe("Room", function() {
|
||||
},
|
||||
];
|
||||
|
||||
room.recalculate(userA);
|
||||
expect(room.currentState.setStateEvents).toHaveBeenCalled();
|
||||
// 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,
|
||||
});
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(roomName);
|
||||
});
|
||||
|
||||
it("should not clobber state events if it isn't an invite room", function() {
|
||||
addMember(userA, "join");
|
||||
stateLookup["m.room.member$" + userA].event.invite_room_state = [
|
||||
const event = addMember(userA, "join");
|
||||
const roomName = "flibble";
|
||||
setRoomName(roomName);
|
||||
const roomNameToIgnore = "ignoreme";
|
||||
event.event.invite_room_state = [
|
||||
{
|
||||
type: "m.room.name",
|
||||
state_key: "",
|
||||
content: {
|
||||
name: "flibble",
|
||||
name: roomNameToIgnore,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
room.recalculate(userA);
|
||||
expect(room.currentState.setStateEvents).toNotHaveBeenCalled();
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(roomName);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Room.recalculate => Room Name using room summary", function() {
|
||||
it("should use room heroes if available", function() {
|
||||
addMember(userA, "invite");
|
||||
addMember(userB);
|
||||
addMember(userC);
|
||||
addMember(userD);
|
||||
room.setSummary({
|
||||
"m.heroes": [userB, userC, userD],
|
||||
});
|
||||
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(`${userB} and 2 others`);
|
||||
});
|
||||
|
||||
it("missing hero member state reverts to mxid", function() {
|
||||
room.setSummary({
|
||||
"m.heroes": [userB],
|
||||
"m.joined_member_count": 2,
|
||||
});
|
||||
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(userB);
|
||||
});
|
||||
|
||||
it("uses hero name from state", function() {
|
||||
const name = "Mr B";
|
||||
addMember(userA, "invite");
|
||||
addMember(userB, "join", {name});
|
||||
room.setSummary({
|
||||
"m.heroes": [userB],
|
||||
});
|
||||
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(name);
|
||||
});
|
||||
|
||||
it("uses counts from summary", function() {
|
||||
const name = "Mr B";
|
||||
addMember(userB, "join", {name});
|
||||
room.setSummary({
|
||||
"m.heroes": [userB],
|
||||
"m.joined_member_count": 50,
|
||||
"m.invited_member_count": 50,
|
||||
});
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(`${name} and 98 others`);
|
||||
});
|
||||
|
||||
it("relies on heroes in case of absent counts", function() {
|
||||
const nameB = "Mr Bean";
|
||||
const nameC = "Mel C";
|
||||
addMember(userB, "join", {name: nameB});
|
||||
addMember(userC, "join", {name: nameC});
|
||||
room.setSummary({
|
||||
"m.heroes": [userB, userC],
|
||||
});
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(`${nameB} and ${nameC}`);
|
||||
});
|
||||
|
||||
it("uses only heroes", function() {
|
||||
const nameB = "Mr Bean";
|
||||
addMember(userB, "join", {name: nameB});
|
||||
addMember(userC, "join");
|
||||
room.setSummary({
|
||||
"m.heroes": [userB],
|
||||
});
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(nameB);
|
||||
});
|
||||
|
||||
it("reverts to empty room in case of self chat", function() {
|
||||
room.setSummary({
|
||||
"m.heroes": [],
|
||||
"m.invited_member_count": 1,
|
||||
});
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual("Empty room");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -751,7 +791,7 @@ describe("Room", function() {
|
||||
addMember(userB);
|
||||
addMember(userC);
|
||||
addMember(userD);
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
// we expect at least 1 member to be mentioned
|
||||
const others = [userB, userC, userD];
|
||||
@@ -772,7 +812,7 @@ describe("Room", function() {
|
||||
addMember(userA);
|
||||
addMember(userB);
|
||||
addMember(userC);
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name.indexOf(userB)).toNotEqual(-1, name);
|
||||
expect(name.indexOf(userC)).toNotEqual(-1, name);
|
||||
@@ -785,7 +825,7 @@ describe("Room", function() {
|
||||
addMember(userA);
|
||||
addMember(userB);
|
||||
addMember(userC);
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name.indexOf(userB)).toNotEqual(-1, name);
|
||||
expect(name.indexOf(userC)).toNotEqual(-1, name);
|
||||
@@ -797,7 +837,7 @@ describe("Room", function() {
|
||||
setJoinRule("public");
|
||||
addMember(userA);
|
||||
addMember(userB);
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name.indexOf(userB)).toNotEqual(-1, name);
|
||||
});
|
||||
@@ -808,7 +848,7 @@ describe("Room", function() {
|
||||
setJoinRule("invite");
|
||||
addMember(userA);
|
||||
addMember(userB);
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name.indexOf(userB)).toNotEqual(-1, name);
|
||||
});
|
||||
@@ -818,7 +858,7 @@ describe("Room", function() {
|
||||
setJoinRule("invite");
|
||||
addMember(userA, "invite", {user: userB});
|
||||
addMember(userB);
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name.indexOf(userB)).toNotEqual(-1, name);
|
||||
});
|
||||
@@ -828,7 +868,7 @@ describe("Room", function() {
|
||||
const alias = "#room_alias:here";
|
||||
setJoinRule("invite");
|
||||
setAliases([alias, "#another:one"]);
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name).toEqual(alias);
|
||||
});
|
||||
@@ -838,7 +878,7 @@ describe("Room", function() {
|
||||
const alias = "#room_alias:here";
|
||||
setJoinRule("public");
|
||||
setAliases([alias, "#another:one"]);
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name).toEqual(alias);
|
||||
});
|
||||
@@ -848,7 +888,7 @@ describe("Room", function() {
|
||||
const roomName = "A mighty name indeed";
|
||||
setJoinRule("invite");
|
||||
setRoomName(roomName);
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name).toEqual(roomName);
|
||||
});
|
||||
@@ -858,25 +898,23 @@ describe("Room", function() {
|
||||
const roomName = "A mighty name indeed";
|
||||
setJoinRule("public");
|
||||
setRoomName(roomName);
|
||||
room.recalculate(userA);
|
||||
const name = room.name;
|
||||
expect(name).toEqual(roomName);
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual(roomName);
|
||||
});
|
||||
|
||||
it("should return 'Empty room' for private (invite join_rules) rooms if" +
|
||||
" a room name and alias don't exist and it is a self-chat.", function() {
|
||||
setJoinRule("invite");
|
||||
addMember(userA);
|
||||
room.recalculate(userA);
|
||||
const name = room.name;
|
||||
expect(name).toEqual("Empty room");
|
||||
room.recalculate();
|
||||
expect(room.name).toEqual("Empty room");
|
||||
});
|
||||
|
||||
it("should return 'Empty room' for public (public join_rules) rooms if a" +
|
||||
" room name and alias don't exist and it is a self-chat.", function() {
|
||||
setJoinRule("public");
|
||||
addMember(userA);
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name).toEqual("Empty room");
|
||||
});
|
||||
@@ -884,7 +922,7 @@ describe("Room", function() {
|
||||
it("should return 'Empty room' if there is no name, " +
|
||||
"alias or members in the room.",
|
||||
function() {
|
||||
room.recalculate(userA);
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name).toEqual("Empty room");
|
||||
});
|
||||
@@ -893,9 +931,9 @@ describe("Room", function() {
|
||||
"available",
|
||||
function() {
|
||||
setJoinRule("invite");
|
||||
addMember(userA, 'join', {name: "Alice"});
|
||||
addMember(userB, "invite", {user: userA});
|
||||
room.recalculate(userB);
|
||||
addMember(userB, 'join', {name: "Alice"});
|
||||
addMember(userA, "invite", {user: userA});
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name).toEqual("Alice");
|
||||
});
|
||||
@@ -903,11 +941,11 @@ describe("Room", function() {
|
||||
it("should return inviter mxid if display name not available",
|
||||
function() {
|
||||
setJoinRule("invite");
|
||||
addMember(userA);
|
||||
addMember(userB, "invite", {user: userA});
|
||||
room.recalculate(userB);
|
||||
addMember(userB);
|
||||
addMember(userA, "invite", {user: userA});
|
||||
room.recalculate();
|
||||
const name = room.name;
|
||||
expect(name).toEqual(userA);
|
||||
expect(name).toEqual(userB);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1154,7 +1192,7 @@ describe("Room", function() {
|
||||
describe("addPendingEvent", function() {
|
||||
it("should add pending events to the pendingEventList if " +
|
||||
"pendingEventOrdering == 'detached'", function() {
|
||||
const room = new Room(roomId, {
|
||||
const room = new Room(roomId, null, userA, {
|
||||
pendingEventOrdering: "detached",
|
||||
});
|
||||
const eventA = utils.mkMessage({
|
||||
@@ -1180,7 +1218,7 @@ describe("Room", function() {
|
||||
|
||||
it("should add pending events to the timeline if " +
|
||||
"pendingEventOrdering == 'chronological'", function() {
|
||||
room = new Room(roomId, {
|
||||
room = new Room(roomId, null, userA, {
|
||||
pendingEventOrdering: "chronological",
|
||||
});
|
||||
const eventA = utils.mkMessage({
|
||||
@@ -1204,7 +1242,7 @@ describe("Room", function() {
|
||||
|
||||
describe("updatePendingEvent", 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",
|
||||
});
|
||||
const eventA = utils.mkMessage({
|
||||
@@ -1240,7 +1278,7 @@ describe("Room", 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({
|
||||
room: roomId, user: userA, event: true,
|
||||
});
|
||||
@@ -1272,4 +1310,153 @@ describe("Room", function() {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,11 @@ describe("SyncAccumulator", function() {
|
||||
member("bob", "join"),
|
||||
],
|
||||
},
|
||||
summary: {
|
||||
"m.heroes": undefined,
|
||||
"m.joined_member_count": undefined,
|
||||
"m.invited_member_count": undefined,
|
||||
},
|
||||
timeline: {
|
||||
events: [msg("alice", "hi")],
|
||||
prev_batch: "something",
|
||||
@@ -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) {
|
||||
|
||||
390
src/autodiscovery.js
Normal file
390
src/autodiscovery.js
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
138
src/base-apis.js
138
src/base-apis.js
@@ -262,7 +262,19 @@ MatrixBaseApis.prototype.login = function(loginType, data, callback) {
|
||||
utils.extend(login_data, data);
|
||||
|
||||
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.
|
||||
*/
|
||||
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,
|
||||
}, httpApi.PREFIX_UNSTABLE);
|
||||
}, httpApi.PREFIX_R0);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -417,6 +443,69 @@ MatrixBaseApis.prototype.roomState = function(roomId, callback) {
|
||||
return this._http.authedRequest(callback, "GET", path);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an event in a room by its event id.
|
||||
* @param {string} roomId
|
||||
* @param {string} eventId
|
||||
* @param {module:client.callback} callback Optional.
|
||||
*
|
||||
* @return {Promise} Resolves to an object containing the event.
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
MatrixBaseApis.prototype.fetchRoomEvent = function(roomId, eventId, callback) {
|
||||
const path = utils.encodeUri(
|
||||
"/rooms/$roomId/event/$eventId", {
|
||||
$roomId: roomId,
|
||||
$eventId: eventId,
|
||||
},
|
||||
);
|
||||
return this._http.authedRequest(callback, "GET", path);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} roomId
|
||||
* @param {string} includeMembership the membership type to include in the response
|
||||
* @param {string} excludeMembership the membership type to exclude from the response
|
||||
* @param {string} atEventId the id of the event for which moment in the timeline the members should be returned for
|
||||
* @param {module:client.callback} callback Optional.
|
||||
* @return {module:client.Promise} Resolves: dictionary of userid to profile information
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
MatrixBaseApis.prototype.members =
|
||||
function(roomId, includeMembership, excludeMembership, atEventId, callback) {
|
||||
const queryParams = {};
|
||||
if (includeMembership) {
|
||||
queryParams.membership = includeMembership;
|
||||
}
|
||||
if (excludeMembership) {
|
||||
queryParams.not_membership = excludeMembership;
|
||||
}
|
||||
if (atEventId) {
|
||||
queryParams.at = atEventId;
|
||||
}
|
||||
|
||||
const queryString = utils.encodeParams(queryParams);
|
||||
|
||||
const path = utils.encodeUri("/rooms/$roomId/members?" + queryString,
|
||||
{$roomId: roomId});
|
||||
return this._http.authedRequest(callback, "GET", path);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upgrades a room to a new protocol version
|
||||
* @param {string} roomId
|
||||
* @param {string} newVersion The target version to upgrade to
|
||||
* @return {module:client.Promise} Resolves: Object with key 'replacement_room'
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
MatrixBaseApis.prototype.upgradeRoom = function(roomId, newVersion) {
|
||||
const path = utils.encodeUri("/rooms/$roomId/upgrade", {$roomId: roomId});
|
||||
return this._http.authedRequest(
|
||||
undefined, "POST", path, undefined, {new_version: newVersion},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} groupId
|
||||
* @return {module:client.Promise} Resolves: Group summary object
|
||||
@@ -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
|
||||
// =========================
|
||||
@@ -1722,7 +1833,7 @@ MatrixBaseApis.prototype.getThirdpartyProtocols = function() {
|
||||
* Get information on how a specific place on a third party protocol
|
||||
* may be reached.
|
||||
* @param {string} protocol The protocol given in getThirdpartyProtocols()
|
||||
* @param {object} params Protocol-specific parameters, as given in th
|
||||
* @param {object} params Protocol-specific parameters, as given in the
|
||||
* response to getThirdpartyProtocols()
|
||||
* @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
|
||||
*/
|
||||
|
||||
785
src/client.js
785
src/client.js
@@ -41,18 +41,46 @@ const SyncApi = require("./sync");
|
||||
const MatrixBaseApis = require("./base-apis");
|
||||
const MatrixError = httpApi.MatrixError;
|
||||
const ContentHelpers = require("./content-helpers");
|
||||
const olmlib = require("./crypto/olmlib");
|
||||
|
||||
import ReEmitter from './ReEmitter';
|
||||
import RoomList from './crypto/RoomList';
|
||||
|
||||
const SCROLLBACK_DELAY_MS = 3000;
|
||||
let CRYPTO_ENABLED = false;
|
||||
import Crypto from './crypto';
|
||||
import { isCryptoAvailable } from './crypto';
|
||||
import { encodeRecoveryKey, decodeRecoveryKey } from './crypto/recoverykey';
|
||||
import { keyForNewBackup, keyForExistingBackup } from './crypto/backup_password';
|
||||
import { randomString } from './randomstring';
|
||||
|
||||
try {
|
||||
var Crypto = require("./crypto");
|
||||
CRYPTO_ENABLED = true;
|
||||
} catch (e) {
|
||||
console.warn("Unable to load crypto module: crypto will be disabled: " + e);
|
||||
// Disable warnings for now: we use deprecated bluebird functions
|
||||
// and need to migrate, but they spam the console with warnings.
|
||||
Promise.config({warnings: false});
|
||||
|
||||
|
||||
const SCROLLBACK_DELAY_MS = 3000;
|
||||
const CRYPTO_ENABLED = isCryptoAvailable();
|
||||
|
||||
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);
|
||||
|
||||
this.olmVersion = null; // Populated after initCrypto is done
|
||||
|
||||
this.reEmitter = new ReEmitter(this);
|
||||
|
||||
this.store = opts.store || new StubStore();
|
||||
@@ -180,10 +210,6 @@ function MatrixClient(opts) {
|
||||
|
||||
this._forceTURN = opts.forceTURN || false;
|
||||
|
||||
if (CRYPTO_ENABLED) {
|
||||
this.olmVersion = Crypto.getOlmVersion();
|
||||
}
|
||||
|
||||
// List of which rooms have encryption enabled: separate from crypto because
|
||||
// we still want to know which rooms are encrypted even if crypto is disabled:
|
||||
// we don't want to start sending unencrypted events to them.
|
||||
@@ -191,6 +217,8 @@ function MatrixClient(opts) {
|
||||
|
||||
// The pushprocessor caches useful things, so keep one and re-use it
|
||||
this._pushProcessor = new PushProcessor(this);
|
||||
|
||||
this._serverSupportsLazyLoading = null;
|
||||
}
|
||||
utils.inherits(MatrixClient, EventEmitter);
|
||||
utils.extend(MatrixClient.prototype, MatrixBaseApis.prototype);
|
||||
@@ -287,6 +315,21 @@ MatrixClient.prototype.getSyncState = function() {
|
||||
return this._syncApi.getSyncState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the additional data object associated with
|
||||
* the current sync state, or null if there is no
|
||||
* such data.
|
||||
* Sync errors, if available, are put in the 'error' key of
|
||||
* this object.
|
||||
* @return {?Object}
|
||||
*/
|
||||
MatrixClient.prototype.getSyncStateData = function() {
|
||||
if (!this._syncApi) {
|
||||
return null;
|
||||
}
|
||||
return this._syncApi.getSyncStateData();
|
||||
};
|
||||
|
||||
/**
|
||||
* Return whether the client is configured for a guest account.
|
||||
* @return {boolean} True if this is a guest access_token (or no token is supplied).
|
||||
@@ -356,6 +399,13 @@ MatrixClient.prototype.setNotifTimelineSet = function(notifTimelineSet) {
|
||||
* successfully initialised.
|
||||
*/
|
||||
MatrixClient.prototype.initCrypto = async function() {
|
||||
if (!isCryptoAvailable()) {
|
||||
throw new Error(
|
||||
`End-to-end encryption not supported in this js-sdk build: did ` +
|
||||
`you remember to load the olm library?`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this._crypto) {
|
||||
console.warn("Attempt to re-initialise e2e encryption on MatrixClient");
|
||||
return;
|
||||
@@ -373,13 +423,6 @@ MatrixClient.prototype.initCrypto = async function() {
|
||||
// initialise the list of encrypted rooms (whether or not crypto is enabled)
|
||||
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();
|
||||
if (userId === null) {
|
||||
throw new Error(
|
||||
@@ -411,6 +454,9 @@ MatrixClient.prototype.initCrypto = async function() {
|
||||
|
||||
await crypto.init();
|
||||
|
||||
this.olmVersion = Crypto.getOlmVersion();
|
||||
|
||||
|
||||
// if crypto initialisation was successful, tell it to attach its event
|
||||
// handlers.
|
||||
crypto.registerEventHandlers(this);
|
||||
@@ -514,7 +560,15 @@ MatrixClient.prototype.setDeviceVerified = function(userId, deviceId, verified)
|
||||
if (verified === undefined) {
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -703,6 +772,333 @@ MatrixClient.prototype.importRoomKeys = function(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
|
||||
// =========
|
||||
// Operations on groups that come down the sync stream (ie. ones the
|
||||
@@ -727,6 +1123,17 @@ MatrixClient.prototype.getGroups = function() {
|
||||
return this.store.getGroups();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the config for the media repository.
|
||||
* @param {module:client.callback} callback Optional.
|
||||
* @return {module:client.Promise} Resolves with an object containing the config.
|
||||
*/
|
||||
MatrixClient.prototype.getMediaConfig = function(callback) {
|
||||
return this._http.authedRequestWithPrefix(
|
||||
callback, "GET", "/config", undefined, undefined, httpApi.PREFIX_MEDIA_R0,
|
||||
);
|
||||
};
|
||||
|
||||
// Room ops
|
||||
// ========
|
||||
|
||||
@@ -750,6 +1157,37 @@ MatrixClient.prototype.getRooms = function() {
|
||||
return this.store.getRooms();
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve all rooms that should be displayed to the user
|
||||
* This is essentially getRooms() with some rooms filtered out, eg. old versions
|
||||
* of rooms that have been replaced or (in future) other rooms that have been
|
||||
* marked at the protocol level as not to be displayed to the user.
|
||||
* @return {Room[]} A list of rooms, or an empty list if there is no data store.
|
||||
*/
|
||||
MatrixClient.prototype.getVisibleRooms = function() {
|
||||
const allRooms = this.store.getRooms();
|
||||
|
||||
const replacedRooms = new Set();
|
||||
for (const r of allRooms) {
|
||||
const createEvent = r.currentState.getStateEvents('m.room.create', '');
|
||||
// invites are included in this list and we don't know their create events yet
|
||||
if (createEvent) {
|
||||
const predecessor = createEvent.getContent()['predecessor'];
|
||||
if (predecessor && predecessor['room_id']) {
|
||||
replacedRooms.add(predecessor['room_id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allRooms.filter((r) => {
|
||||
const tombstone = r.currentState.getStateEvents('m.room.tombstone', '');
|
||||
if (tombstone && replacedRooms.has(r.roomId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve a user.
|
||||
* @param {string} userId The user ID to retrieve.
|
||||
@@ -842,6 +1280,8 @@ MatrixClient.prototype.isUserIgnored = function(userId) {
|
||||
* </strong> Default: true.
|
||||
* @param {boolean} opts.inviteSignUrl If the caller has a keypair 3pid invite,
|
||||
* the signing URL is passed in this parameter.
|
||||
* @param {string[]} opts.viaServers The server names to try and join through in
|
||||
* addition to those that are automatically chosen.
|
||||
* @param {module:client.callback} callback Optional.
|
||||
* @return {module:client.Promise} Resolves: Room object.
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
@@ -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 self = this;
|
||||
@@ -880,7 +1327,8 @@ MatrixClient.prototype.joinRoom = function(roomIdOrAlias, opts, callback) {
|
||||
}
|
||||
|
||||
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) {
|
||||
const roomId = res.room_id;
|
||||
const syncApi = new SyncApi(self, self._clientOpts);
|
||||
@@ -1100,6 +1548,13 @@ MatrixClient.prototype.sendEvent = function(roomId, eventType, content, 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);
|
||||
};
|
||||
|
||||
@@ -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 {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
|
||||
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();
|
||||
info = {
|
||||
promise: defer.promise,
|
||||
@@ -1936,9 +2404,17 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) {
|
||||
// wait for a time before doing this request
|
||||
// (which may be 0 in order not to special case the code paths)
|
||||
Promise.delay(timeToWaitMs).then(function() {
|
||||
return self._http.authedRequest(callback, "GET", path, params);
|
||||
return self._createMessagesRequest(
|
||||
room.roomId,
|
||||
room.oldState.paginationToken,
|
||||
limit,
|
||||
'b');
|
||||
}).done(function(res) {
|
||||
const matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self));
|
||||
if (res.state) {
|
||||
const stateEvents = utils.map(res.state, _PojoToMatrixEventMapper(self));
|
||||
room.currentState.setUnknownStateEvents(stateEvents);
|
||||
}
|
||||
room.addEventsToTimeline(matrixEvents, true, room.getLiveTimeline());
|
||||
room.oldState.paginationToken = res.end;
|
||||
if (res.chunk.length === 0) {
|
||||
@@ -1957,73 +2433,6 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) {
|
||||
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
|
||||
*
|
||||
@@ -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
|
||||
// nicely with HTTP errors.
|
||||
const self = this;
|
||||
const promise =
|
||||
self._http.authedRequest(undefined, "GET", path,
|
||||
self._http.authedRequest(undefined, "GET", path, params,
|
||||
).then(function(res) {
|
||||
if (!res.event) {
|
||||
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,
|
||||
self.getEventMapper()));
|
||||
timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end;
|
||||
} else {
|
||||
const stateEvents = utils.map(res.state, self.getEventMapper());
|
||||
timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(stateEvents);
|
||||
}
|
||||
timelineSet.addEventsToTimeline(matrixEvents, true, timeline, res.start);
|
||||
|
||||
@@ -2102,6 +2519,49 @@ MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) {
|
||||
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.
|
||||
@@ -2196,25 +2656,18 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) {
|
||||
throw new Error("Unknown room " + eventTimeline.getRoomId());
|
||||
}
|
||||
|
||||
path = utils.encodeUri(
|
||||
"/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()},
|
||||
);
|
||||
params = {
|
||||
from: token,
|
||||
limit: ('limit' in opts) ? opts.limit : 30,
|
||||
dir: dir,
|
||||
};
|
||||
|
||||
const filter = eventTimeline.getFilter();
|
||||
if (filter) {
|
||||
// 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) {
|
||||
promise = this._createMessagesRequest(
|
||||
eventTimeline.getRoomId(),
|
||||
token,
|
||||
opts.limit,
|
||||
dir,
|
||||
eventTimeline.getFilter());
|
||||
promise.then(function(res) {
|
||||
if (res.state) {
|
||||
const roomState = eventTimeline.getState(dir);
|
||||
const stateEvents = utils.map(res.state, self.getEventMapper());
|
||||
roomState.setUnknownStateEvents(stateEvents);
|
||||
}
|
||||
const token = res.end;
|
||||
const matrixEvents = utils.map(res.chunk, self.getEventMapper());
|
||||
eventTimeline.getTimelineSet()
|
||||
@@ -3019,8 +3472,11 @@ MatrixClient.prototype.getTurnServers = function() {
|
||||
*
|
||||
* @param {Boolean=} opts.disablePresence True to perform syncing without automatically
|
||||
* updating presence.
|
||||
* @param {Boolean=} opts.lazyLoadMembers True to not load all membership events during
|
||||
* initial sync but fetch them when needed by calling `loadOutOfBandMembers`
|
||||
* This will override the filter option at this moment.
|
||||
*/
|
||||
MatrixClient.prototype.startClient = function(opts) {
|
||||
MatrixClient.prototype.startClient = async function(opts) {
|
||||
if (this.clientRunning) {
|
||||
// client is already running.
|
||||
return;
|
||||
@@ -3058,11 +3514,29 @@ MatrixClient.prototype.startClient = function(opts) {
|
||||
return this._canResetTimelineCallback(roomId);
|
||||
};
|
||||
this._clientOpts = opts;
|
||||
|
||||
this._syncApi = new SyncApi(this, opts);
|
||||
this._syncApi.sync();
|
||||
};
|
||||
|
||||
/**
|
||||
* store client options with boolean/string/numeric values
|
||||
* to know in the next session what flags the sync data was
|
||||
* created with (e.g. lazy loading)
|
||||
* @param {object} opts the complete set of client options
|
||||
* @return {Promise} for store operation */
|
||||
MatrixClient.prototype._storeClientOptions = function() {
|
||||
const primTypes = ["boolean", "string", "number"];
|
||||
const serializableOpts = Object.entries(this._clientOpts)
|
||||
.filter(([key, value]) => {
|
||||
return primTypes.includes(typeof value);
|
||||
})
|
||||
.reduce((obj, [key, value]) => {
|
||||
obj[key] = value;
|
||||
return obj;
|
||||
}, {});
|
||||
return this.store.storeClientOptions(serializableOpts);
|
||||
};
|
||||
|
||||
/**
|
||||
* High level helper method to stop the client from polling and allow a
|
||||
* clean shutdown.
|
||||
@@ -3085,6 +3559,36 @@ MatrixClient.prototype.stopClient = function() {
|
||||
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.
|
||||
* 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
|
||||
*/
|
||||
MatrixClient.prototype.generateClientSecret = function() {
|
||||
let ret = "";
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
for (let i = 0; i < 32; i++) {
|
||||
ret += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
|
||||
return ret;
|
||||
return randomString(32);
|
||||
};
|
||||
|
||||
/** */
|
||||
@@ -3430,6 +3927,12 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
|
||||
* a state of SYNCING. <i>This is the equivalent of "syncComplete" in the
|
||||
* 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.
|
||||
* This will be called <i>after</i> processing latest events from a sync.</li>
|
||||
*
|
||||
@@ -3453,11 +3956,11 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
|
||||
* +---->STOPPED
|
||||
* |
|
||||
* +----->PREPARED -------> SYNCING <--+
|
||||
* | ^ | ^ |
|
||||
* | | | | |
|
||||
* | | V | |
|
||||
* null ------+ | +--------RECONNECTING |
|
||||
* | | V |
|
||||
* | ^ | ^ |
|
||||
* | CATCHUP ----------+ | | |
|
||||
* | ^ V | |
|
||||
* null ------+ | +------- RECONNECTING |
|
||||
* | V V |
|
||||
* +------->ERROR ---------------------+
|
||||
*
|
||||
* 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 {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.
|
||||
* <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
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ limitations under the License.
|
||||
|
||||
import Promise from 'bluebird';
|
||||
|
||||
import logger from '../logger';
|
||||
import DeviceInfo from './deviceinfo';
|
||||
import olmlib from './olmlib';
|
||||
import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
|
||||
@@ -71,6 +72,9 @@ export default class DeviceList {
|
||||
// }
|
||||
this._devices = {};
|
||||
|
||||
// map of identity keys to the user who owns it
|
||||
this._userByIdentityKey = {};
|
||||
|
||||
// which users we are tracking device status for.
|
||||
// userId -> TRACKING_STATUS_*
|
||||
this._deviceTrackingStatus = {}; // loaded from storage in load()
|
||||
@@ -110,7 +114,7 @@ export default class DeviceList {
|
||||
'readwrite', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
|
||||
this._cryptoStore.getEndToEndDeviceData(txn, (deviceData) => {
|
||||
if (deviceData === null) {
|
||||
console.log("Migrating e2e device data...");
|
||||
logger.log("Migrating e2e device data...");
|
||||
this._devices = this._sessionStore.getAllEndToEndDevices() || {};
|
||||
this._deviceTrackingStatus = (
|
||||
this._sessionStore.getEndToEndDeviceTrackingStatus() || {}
|
||||
@@ -128,6 +132,16 @@ export default class DeviceList {
|
||||
deviceData.trackingStatus : {};
|
||||
this._syncToken = deviceData ? deviceData.syncToken : null;
|
||||
}
|
||||
this._userByIdentityKey = {};
|
||||
for (const user of Object.keys(this._devices)) {
|
||||
const userDevices = this._devices[user];
|
||||
for (const device of Object.keys(userDevices)) {
|
||||
const idKey = userDevices[device].keys['curve25519:'+device];
|
||||
if (idKey !== undefined) {
|
||||
this._userByIdentityKey[idKey] = user;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
* pending other than updating the sync token
|
||||
@@ -190,7 +210,7 @@ export default class DeviceList {
|
||||
const resolveSavePromise = this._resolveSavePromise;
|
||||
this._savePromiseTime = targetTime;
|
||||
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),
|
||||
// otherwise we could return the existing promise when the save has
|
||||
// actually already happened. Likewise for the dirty flag.
|
||||
@@ -258,7 +278,7 @@ export default class DeviceList {
|
||||
if (this._keyDownloadsInProgressByUser[u]) {
|
||||
// already a key download in progress/queued for this user; its results
|
||||
// will be good enough for us.
|
||||
console.log(
|
||||
logger.log(
|
||||
`downloadKeys: already have a download in progress for ` +
|
||||
`${u}: awaiting its result`,
|
||||
);
|
||||
@@ -269,13 +289,13 @@ export default class DeviceList {
|
||||
});
|
||||
|
||||
if (usersToDownload.length != 0) {
|
||||
console.log("downloadKeys: downloading for", usersToDownload);
|
||||
logger.log("downloadKeys: downloading for", usersToDownload);
|
||||
const downloadPromise = this._doKeyDownload(usersToDownload);
|
||||
promises.push(downloadPromise);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
@@ -357,13 +377,17 @@ export default class DeviceList {
|
||||
/**
|
||||
* Find a device by curve25519 identity key
|
||||
*
|
||||
* @param {string} userId owner of the device
|
||||
* @param {string} algorithm encryption algorithm
|
||||
* @param {string} senderKey curve25519 key to match
|
||||
*
|
||||
* @return {module:crypto/deviceinfo?}
|
||||
*/
|
||||
getDeviceByIdentityKey(userId, algorithm, senderKey) {
|
||||
getDeviceByIdentityKey(algorithm, senderKey) {
|
||||
const userId = this._userByIdentityKey[senderKey];
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
algorithm !== olmlib.OLM_ALGORITHM &&
|
||||
algorithm !== olmlib.MEGOLM_ALGORITHM
|
||||
@@ -408,7 +432,23 @@ export default class DeviceList {
|
||||
* @param {Object} devs New device info for user
|
||||
*/
|
||||
storeDevicesForUser(u, devs) {
|
||||
// remove previous devices from _userByIdentityKey
|
||||
if (this._devices[u] !== undefined) {
|
||||
for (const [deviceId, dev] of Object.entries(this._devices[u])) {
|
||||
const identityKey = dev.keys['curve25519:'+deviceId];
|
||||
|
||||
delete this._userByIdentityKey[identityKey];
|
||||
}
|
||||
}
|
||||
|
||||
this._devices[u] = devs;
|
||||
|
||||
// add new ones
|
||||
for (const [deviceId, dev] of Object.entries(devs)) {
|
||||
const identityKey = dev.keys['curve25519:'+deviceId];
|
||||
|
||||
this._userByIdentityKey[identityKey] = u;
|
||||
}
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
@@ -433,7 +473,7 @@ export default class DeviceList {
|
||||
throw new Error('userId must be a string; was '+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;
|
||||
}
|
||||
// we don't yet persist the tracking status, since there may be a lot
|
||||
@@ -452,7 +492,7 @@ export default class DeviceList {
|
||||
*/
|
||||
stopTrackingDeviceList(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;
|
||||
|
||||
// we don't yet persist the tracking status, since there may be a lot
|
||||
@@ -487,7 +527,7 @@ export default class DeviceList {
|
||||
*/
|
||||
invalidateUserDeviceList(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;
|
||||
|
||||
// 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
|
||||
*/
|
||||
_setRawStoredDevicesForUser(userId, devices) {
|
||||
// remove old devices from _userByIdentityKey
|
||||
if (this._devices[userId] !== undefined) {
|
||||
for (const [deviceId, dev] of Object.entries(this._devices[userId])) {
|
||||
const identityKey = dev.keys['curve25519:'+deviceId];
|
||||
|
||||
delete this._userByIdentityKey[identityKey];
|
||||
}
|
||||
}
|
||||
|
||||
this._devices[userId] = devices;
|
||||
|
||||
// add new devices into _userByIdentityKey
|
||||
for (const [deviceId, dev] of Object.entries(devices)) {
|
||||
const identityKey = dev.keys['curve25519:'+deviceId];
|
||||
|
||||
this._userByIdentityKey[identityKey] = userId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -550,7 +606,7 @@ export default class DeviceList {
|
||||
).then(() => {
|
||||
finished(true);
|
||||
}, (e) => {
|
||||
console.error(
|
||||
logger.error(
|
||||
'Error downloading keys for ' + users + ":", e,
|
||||
);
|
||||
finished(false);
|
||||
@@ -573,7 +629,7 @@ export default class DeviceList {
|
||||
// since we started this request. If that happens, we should
|
||||
// ignore the completion of the first one.
|
||||
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');
|
||||
return;
|
||||
}
|
||||
@@ -584,7 +640,7 @@ export default class DeviceList {
|
||||
// we didn't get any new invalidations since this download started:
|
||||
// this user's device list is now up to date.
|
||||
this._deviceTrackingStatus[u] = TRACKING_STATUS_UP_TO_DATE;
|
||||
console.log("Device list for", u, "now up to date");
|
||||
logger.log("Device list for", u, "now up to date");
|
||||
} else {
|
||||
this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD;
|
||||
}
|
||||
@@ -659,7 +715,7 @@ class DeviceListUpdateSerialiser {
|
||||
|
||||
if (this._downloadInProgress) {
|
||||
// just queue up these users
|
||||
console.log('Queued key download for', users);
|
||||
logger.log('Queued key download for', users);
|
||||
return this._queuedQueryDeferred.promise;
|
||||
}
|
||||
|
||||
@@ -679,7 +735,7 @@ class DeviceListUpdateSerialiser {
|
||||
const deferred = this._queuedQueryDeferred;
|
||||
this._queuedQueryDeferred = null;
|
||||
|
||||
console.log('Starting key download for', downloadUsers);
|
||||
logger.log('Starting key download for', downloadUsers);
|
||||
this._downloadInProgress = true;
|
||||
|
||||
const opts = {};
|
||||
@@ -706,7 +762,7 @@ class DeviceListUpdateSerialiser {
|
||||
|
||||
return prom;
|
||||
}).done(() => {
|
||||
console.log('Completed key download for ' + downloadUsers);
|
||||
logger.log('Completed key download for ' + downloadUsers);
|
||||
|
||||
this._downloadInProgress = false;
|
||||
deferred.resolve();
|
||||
@@ -716,7 +772,7 @@ class DeviceListUpdateSerialiser {
|
||||
this._doQueuedQueries();
|
||||
}
|
||||
}, (e) => {
|
||||
console.warn('Error downloading keys for ' + downloadUsers + ':', e);
|
||||
logger.warn('Error downloading keys for ' + downloadUsers + ':', e);
|
||||
this._downloadInProgress = false;
|
||||
deferred.reject(e);
|
||||
});
|
||||
@@ -725,7 +781,7 @@ class DeviceListUpdateSerialiser {
|
||||
}
|
||||
|
||||
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
|
||||
const userStore = {};
|
||||
@@ -763,7 +819,7 @@ async function _updateStoredDeviceKeysForUser(_olmDevice, userId, userStore,
|
||||
}
|
||||
|
||||
if (!(deviceId in userResult)) {
|
||||
console.log("Device " + userId + ":" + deviceId +
|
||||
logger.log("Device " + userId + ":" + deviceId +
|
||||
" has been removed");
|
||||
delete userStore[deviceId];
|
||||
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
|
||||
// correct
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
@@ -815,7 +871,7 @@ async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) {
|
||||
const signKeyId = "ed25519:" + deviceId;
|
||||
const signKey = deviceResult.keys[signKeyId];
|
||||
if (!signKey) {
|
||||
console.warn("Device " + userId + ":" + deviceId +
|
||||
logger.warn("Device " + userId + ":" + deviceId +
|
||||
" has no ed25519 key");
|
||||
return false;
|
||||
}
|
||||
@@ -825,7 +881,7 @@ async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) {
|
||||
try {
|
||||
await olmlib.verifySignature(_olmDevice, deviceResult, userId, deviceId, signKey);
|
||||
} catch (e) {
|
||||
console.warn("Unable to verify signature on device " +
|
||||
logger.warn("Unable to verify signature on device " +
|
||||
userId + ":" + deviceId + ":" + e);
|
||||
return false;
|
||||
}
|
||||
@@ -842,7 +898,7 @@ async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) {
|
||||
// best off sticking with the original keys.
|
||||
//
|
||||
// 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");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -15,19 +15,9 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import logger from '../logger';
|
||||
import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
|
||||
|
||||
/**
|
||||
* 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
|
||||
// reasonable approximation to the biggest plaintext we can encrypt.
|
||||
const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4;
|
||||
@@ -127,7 +117,7 @@ OlmDevice.prototype.init = async function() {
|
||||
await this._migrateFromSessionStore();
|
||||
|
||||
let e2eKeys;
|
||||
const account = new Olm.Account();
|
||||
const account = new global.Olm.Account();
|
||||
try {
|
||||
await _initialiseAccount(
|
||||
this._sessionStore, this._cryptoStore, this._pickleKey, account,
|
||||
@@ -161,7 +151,7 @@ async function _initialiseAccount(sessionStore, cryptoStore, pickleKey, account)
|
||||
* @return {array} The version of Olm.
|
||||
*/
|
||||
OlmDevice.getOlmVersion = function() {
|
||||
return Olm.get_library_version();
|
||||
return global.Olm.get_library_version();
|
||||
};
|
||||
|
||||
OlmDevice.prototype._migrateFromSessionStore = async function() {
|
||||
@@ -173,7 +163,7 @@ OlmDevice.prototype._migrateFromSessionStore = async function() {
|
||||
// Migrate from sessionStore
|
||||
pickledAccount = this._sessionStore.getEndToEndAccount();
|
||||
if (pickledAccount !== null) {
|
||||
console.log("Migrating account from session store");
|
||||
logger.log("Migrating account from session store");
|
||||
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.
|
||||
this._cryptoStore.countEndToEndSessions(txn, (count) => {
|
||||
if (count) {
|
||||
console.log("Crypto store already has sessions: not migrating");
|
||||
logger.log("Crypto store already has sessions: not migrating");
|
||||
return;
|
||||
}
|
||||
let numSessions = 0;
|
||||
@@ -207,7 +197,7 @@ OlmDevice.prototype._migrateFromSessionStore = async function() {
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
logger.log(
|
||||
"Migrating " + numSessions + " sessions from session store",
|
||||
);
|
||||
});
|
||||
@@ -236,14 +226,14 @@ OlmDevice.prototype._migrateFromSessionStore = async function() {
|
||||
), txn,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
"Failed to migrate session " + s.senderKey + "/" +
|
||||
s.sessionId + ": " + e.stack || e,
|
||||
);
|
||||
}
|
||||
++numIbSessions;
|
||||
}
|
||||
console.log(
|
||||
logger.log(
|
||||
"Migrated " + numIbSessions +
|
||||
" inbound group sessions from session store",
|
||||
);
|
||||
@@ -268,7 +258,7 @@ OlmDevice.prototype._migrateFromSessionStore = async function() {
|
||||
*/
|
||||
OlmDevice.prototype._getAccount = function(txn, func) {
|
||||
this._cryptoStore.getAccount(txn, (pickledAccount) => {
|
||||
const account = new Olm.Account();
|
||||
const account = new global.Olm.Account();
|
||||
try {
|
||||
account.unpickle(this._pickleKey, pickledAccount);
|
||||
func(account);
|
||||
@@ -305,8 +295,8 @@ OlmDevice.prototype._storeAccount = function(txn, account) {
|
||||
*/
|
||||
OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) {
|
||||
this._cryptoStore.getEndToEndSession(
|
||||
deviceKey, sessionId, txn, (pickledSession) => {
|
||||
this._unpickleSession(pickledSession, func);
|
||||
deviceKey, sessionId, txn, (sessionInfo) => {
|
||||
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
|
||||
* returns.
|
||||
*
|
||||
* @param {string} pickledSession
|
||||
* @param {object} sessionInfo
|
||||
* @param {function} func
|
||||
* @private
|
||||
*/
|
||||
OlmDevice.prototype._unpickleSession = function(pickledSession, func) {
|
||||
const session = new Olm.Session();
|
||||
OlmDevice.prototype._unpickleSession = function(sessionInfo, func) {
|
||||
const session = new global.Olm.Session();
|
||||
try {
|
||||
session.unpickle(this._pickleKey, pickledSession);
|
||||
func(session);
|
||||
session.unpickle(this._pickleKey, sessionInfo.session);
|
||||
const unpickledSessInfo = Object.assign({}, sessionInfo, {session});
|
||||
|
||||
func(unpickledSessInfo);
|
||||
} finally {
|
||||
session.free();
|
||||
}
|
||||
@@ -334,14 +326,17 @@ OlmDevice.prototype._unpickleSession = function(pickledSession, func) {
|
||||
* store our OlmSession in the session store
|
||||
*
|
||||
* @param {string} deviceKey
|
||||
* @param {OlmSession} session
|
||||
* @param {object} sessionInfo {session: OlmSession, lastReceivedMessageTs: int}
|
||||
* @param {*} txn Opaque transaction object from cryptoStore.doTxn()
|
||||
* @private
|
||||
*/
|
||||
OlmDevice.prototype._saveSession = function(deviceKey, session, txn) {
|
||||
const pickledSession = session.pickle(this._pickleKey);
|
||||
OlmDevice.prototype._saveSession = function(deviceKey, sessionInfo, txn) {
|
||||
const sessionId = sessionInfo.session.session_id();
|
||||
const pickledSessionInfo = Object.assign(sessionInfo, {
|
||||
session: sessionInfo.session.pickle(this._pickleKey),
|
||||
});
|
||||
this._cryptoStore.storeEndToEndSession(
|
||||
deviceKey, session.session_id(), pickledSession, txn,
|
||||
deviceKey, sessionId, pickledSessionInfo, txn,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -354,7 +349,7 @@ OlmDevice.prototype._saveSession = function(deviceKey, session, txn) {
|
||||
* @private
|
||||
*/
|
||||
OlmDevice.prototype._getUtility = function(func) {
|
||||
const utility = new Olm.Utility();
|
||||
const utility = new global.Olm.Utility();
|
||||
try {
|
||||
return func(utility);
|
||||
} finally {
|
||||
@@ -466,12 +461,19 @@ OlmDevice.prototype.createOutboundSession = async function(
|
||||
],
|
||||
(txn) => {
|
||||
this._getAccount(txn, (account) => {
|
||||
const session = new Olm.Session();
|
||||
const session = new global.Olm.Session();
|
||||
try {
|
||||
session.create_outbound(account, theirIdentityKey, theirOneTimeKey);
|
||||
newSessionId = session.session_id();
|
||||
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 {
|
||||
session.free();
|
||||
}
|
||||
@@ -510,7 +512,7 @@ OlmDevice.prototype.createInboundSession = async function(
|
||||
],
|
||||
(txn) => {
|
||||
this._getAccount(txn, (account) => {
|
||||
const session = new Olm.Session();
|
||||
const session = new global.Olm.Session();
|
||||
try {
|
||||
session.create_inbound_from(
|
||||
account, theirDeviceIdentityKey, ciphertext,
|
||||
@@ -520,7 +522,13 @@ OlmDevice.prototype.createInboundSession = async function(
|
||||
|
||||
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 = {
|
||||
payload: payloadString,
|
||||
@@ -568,13 +576,30 @@ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityK
|
||||
* @return {Promise<?string>} session id, or null if no established session
|
||||
*/
|
||||
OlmDevice.prototype.getSessionIdForDevice = async function(theirDeviceIdentityKey) {
|
||||
const sessionIds = await this.getSessionIdsForDevice(theirDeviceIdentityKey);
|
||||
if (sessionIds.length === 0) {
|
||||
const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey);
|
||||
if (sessionInfos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Use the session with the lowest ID.
|
||||
sessionIds.sort();
|
||||
return sessionIds[0];
|
||||
// Use the session that has most recently received a message
|
||||
let idxOfBest = 0;
|
||||
for (let i = 1; i < sessionInfos.length; i++) {
|
||||
const thisSessInfo = sessionInfos[i];
|
||||
const thisLastReceived = thisSessInfo.lastReceivedMessageTs === undefined ?
|
||||
0 : thisSessInfo.lastReceivedMessageTs;
|
||||
|
||||
const bestSessInfo = sessionInfos[idxOfBest];
|
||||
const bestLastReceived = bestSessInfo.lastReceivedMessageTs === undefined ?
|
||||
0 : bestSessInfo.lastReceivedMessageTs;
|
||||
if (
|
||||
thisLastReceived > bestLastReceived || (
|
||||
thisLastReceived === bestLastReceived &&
|
||||
thisSessInfo.sessionId < bestSessInfo.sessionId
|
||||
)
|
||||
) {
|
||||
idxOfBest = i;
|
||||
}
|
||||
}
|
||||
return sessionInfos[idxOfBest].sessionId;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -597,9 +622,10 @@ OlmDevice.prototype.getSessionInfoForDevice = async function(deviceIdentityKey)
|
||||
this._cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => {
|
||||
const sessionIds = Object.keys(sessions).sort();
|
||||
for (const sessionId of sessionIds) {
|
||||
this._unpickleSession(sessions[sessionId], (session) => {
|
||||
this._unpickleSession(sessions[sessionId], (sessInfo) => {
|
||||
info.push({
|
||||
hasReceivedMessage: session.has_received_message(),
|
||||
lastReceivedMessageTs: sessInfo.lastReceivedMessageTs,
|
||||
hasReceivedMessage: sessInfo.session.has_received_message(),
|
||||
sessionId: sessionId,
|
||||
});
|
||||
});
|
||||
@@ -630,9 +656,9 @@ OlmDevice.prototype.encryptMessage = async function(
|
||||
await this._cryptoStore.doTxn(
|
||||
'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS],
|
||||
(txn) => {
|
||||
this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => {
|
||||
res = session.encrypt(payloadString);
|
||||
this._saveSession(theirDeviceIdentityKey, session, txn);
|
||||
this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => {
|
||||
res = sessionInfo.session.encrypt(payloadString);
|
||||
this._saveSession(theirDeviceIdentityKey, sessionInfo, txn);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -657,9 +683,10 @@ OlmDevice.prototype.decryptMessage = async function(
|
||||
await this._cryptoStore.doTxn(
|
||||
'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS],
|
||||
(txn) => {
|
||||
this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => {
|
||||
payloadString = session.decrypt(messageType, ciphertext);
|
||||
this._saveSession(theirDeviceIdentityKey, session, txn);
|
||||
this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => {
|
||||
payloadString = sessionInfo.session.decrypt(messageType, ciphertext);
|
||||
sessionInfo.lastReceivedMessageTs = Date.now();
|
||||
this._saveSession(theirDeviceIdentityKey, sessionInfo, txn);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -689,8 +716,8 @@ OlmDevice.prototype.matchesSession = async function(
|
||||
await this._cryptoStore.doTxn(
|
||||
'readonly', [IndexedDBCryptoStore.STORE_SESSIONS],
|
||||
(txn) => {
|
||||
this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => {
|
||||
matches = session.matches_inbound(ciphertext);
|
||||
this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => {
|
||||
matches = sessionInfo.session.matches_inbound(ciphertext);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -724,11 +751,11 @@ OlmDevice.prototype._saveOutboundGroupSession = function(session) {
|
||||
*/
|
||||
OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) {
|
||||
const pickled = this._outboundGroupSessionStore[sessionId];
|
||||
if (pickled === null) {
|
||||
if (pickled === undefined) {
|
||||
throw new Error("Unknown outbound group session " + sessionId);
|
||||
}
|
||||
|
||||
const session = new Olm.OutboundGroupSession();
|
||||
const session = new global.Olm.OutboundGroupSession();
|
||||
try {
|
||||
session.unpickle(this._pickleKey, pickled);
|
||||
return func(session);
|
||||
@@ -744,7 +771,7 @@ OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) {
|
||||
* @return {string} sessionId for the outbound session.
|
||||
*/
|
||||
OlmDevice.prototype.createOutboundGroupSession = function() {
|
||||
const session = new Olm.OutboundGroupSession();
|
||||
const session = new global.Olm.OutboundGroupSession();
|
||||
try {
|
||||
session.create();
|
||||
this._saveOutboundGroupSession(session);
|
||||
@@ -816,7 +843,7 @@ OlmDevice.prototype.getOutboundGroupSessionKey = function(sessionId) {
|
||||
* @return {*} result of func
|
||||
*/
|
||||
OlmDevice.prototype._unpickleInboundGroupSession = function(sessionData, func) {
|
||||
const session = new Olm.InboundGroupSession();
|
||||
const session = new global.Olm.InboundGroupSession();
|
||||
try {
|
||||
session.unpickle(this._pickleKey, sessionData.session);
|
||||
return func(session);
|
||||
@@ -889,7 +916,7 @@ OlmDevice.prototype.addInboundGroupSession = async function(
|
||||
roomId, senderKey, sessionId, txn,
|
||||
(existingSession, existingSessionData) => {
|
||||
if (existingSession) {
|
||||
console.log(
|
||||
logger.log(
|
||||
"Update for megolm session " + senderKey + "/" + sessionId,
|
||||
);
|
||||
// for now we just ignore updates. TODO: implement something here
|
||||
@@ -897,7 +924,7 @@ OlmDevice.prototype.addInboundGroupSession = async function(
|
||||
}
|
||||
|
||||
// new session.
|
||||
const session = new Olm.InboundGroupSession();
|
||||
const session = new global.Olm.InboundGroupSession();
|
||||
try {
|
||||
if (exportFormat) {
|
||||
session.import_session(sessionKey);
|
||||
@@ -1034,7 +1061,7 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se
|
||||
}
|
||||
|
||||
if (roomId !== sessionData.room_id) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`requested keys for inbound group session ${senderKey}|` +
|
||||
`${sessionId}, with incorrect 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} senderKey base64-encoded curve25519 key of the sender
|
||||
* @param {string} sessionId session identifier
|
||||
* @param {integer} chainIndex The chain index at which to export the session.
|
||||
* If omitted, export at the first index we know about.
|
||||
*
|
||||
* @returns {Promise<{chain_index: number, key: string,
|
||||
* forwarding_curve25519_key_chain: Array<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
|
||||
* export format.
|
||||
*
|
||||
* @throws Error If the given chain index could not be obtained from the known
|
||||
* index (ie. the given chain index is before the first we have).
|
||||
*/
|
||||
OlmDevice.prototype.getInboundGroupSessionKey = async function(
|
||||
roomId, senderKey, sessionId,
|
||||
roomId, senderKey, sessionId, chainIndex,
|
||||
) {
|
||||
let result;
|
||||
await this._cryptoStore.doTxn(
|
||||
@@ -1078,14 +1110,19 @@ OlmDevice.prototype.getInboundGroupSessionKey = async function(
|
||||
result = null;
|
||||
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 senderEd25519Key = claimedKeys.ed25519 || null;
|
||||
|
||||
result = {
|
||||
"chain_index": messageIndex,
|
||||
"key": session.export_session(messageIndex),
|
||||
"chain_index": chainIndex,
|
||||
"key": exportedSession,
|
||||
"forwarding_curve25519_key_chain":
|
||||
sessionData.forwardingCurve25519KeyChain || [],
|
||||
"sender_claimed_ed25519_key": senderEd25519Key,
|
||||
@@ -1119,6 +1156,7 @@ OlmDevice.prototype.exportInboundGroupSession = function(
|
||||
"session_id": sessionId,
|
||||
"session_key": session.export_session(messageIndex),
|
||||
"forwarding_curve25519_key_chain": session.forwardingCurve25519KeyChain || [],
|
||||
"first_known_index": session.first_known_index(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
import Promise from 'bluebird';
|
||||
|
||||
import logger from '../logger';
|
||||
import utils from '../utils';
|
||||
|
||||
/**
|
||||
@@ -108,7 +109,7 @@ export default class OutgoingRoomKeyRequestManager {
|
||||
* Called when the client is stopped. Stops any running background processes.
|
||||
*/
|
||||
stop() {
|
||||
console.log('stopping OutgoingRoomKeyRequestManager');
|
||||
logger.log('stopping OutgoingRoomKeyRequestManager');
|
||||
// stop the timer on the next run
|
||||
this._clientRunning = false;
|
||||
}
|
||||
@@ -173,7 +174,7 @@ export default class OutgoingRoomKeyRequestManager {
|
||||
// may have seen it, so we still need to send a cancellation
|
||||
// in that case :/
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
'deleting unnecessary room key request for ' +
|
||||
stringifyRequestBody(requestBody),
|
||||
);
|
||||
@@ -201,7 +202,7 @@ export default class OutgoingRoomKeyRequestManager {
|
||||
// the request cancelled. There is no point in
|
||||
// sending another cancellation since the other tab
|
||||
// will do it.
|
||||
console.log(
|
||||
logger.log(
|
||||
'Tried to cancel room key request for ' +
|
||||
stringifyRequestBody(requestBody) +
|
||||
' but it was already cancelled in another tab',
|
||||
@@ -222,7 +223,7 @@ export default class OutgoingRoomKeyRequestManager {
|
||||
updatedReq,
|
||||
andResend,
|
||||
).catch((e) => {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Error sending room key request cancellation;"
|
||||
+ " 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
|
||||
// already running
|
||||
_startTimer() {
|
||||
@@ -261,7 +277,7 @@ export default class OutgoingRoomKeyRequestManager {
|
||||
}).catch((e) => {
|
||||
// this should only happen if there is an indexeddb error,
|
||||
// in which case we're a bit stuffed anyway.
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`error in OutgoingRoomKeyRequestManager: ${e}`,
|
||||
);
|
||||
}).done();
|
||||
@@ -282,7 +298,7 @@ export default class OutgoingRoomKeyRequestManager {
|
||||
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([
|
||||
ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING,
|
||||
@@ -290,7 +306,7 @@ export default class OutgoingRoomKeyRequestManager {
|
||||
ROOM_KEY_REQUEST_STATES.UNSENT,
|
||||
]).then((req) => {
|
||||
if (!req) {
|
||||
console.log("No more outgoing room key requests");
|
||||
logger.log("No more outgoing room key requests");
|
||||
this._sendOutgoingRoomKeyRequestsTimer = null;
|
||||
return;
|
||||
}
|
||||
@@ -312,7 +328,7 @@ export default class OutgoingRoomKeyRequestManager {
|
||||
// go around the loop again
|
||||
return this._sendOutgoingRoomKeyRequests();
|
||||
}).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._startTimer();
|
||||
}).done();
|
||||
@@ -321,7 +337,7 @@ export default class OutgoingRoomKeyRequestManager {
|
||||
|
||||
// given a RoomKeyRequest, send it and update the request record
|
||||
_sendOutgoingRoomKeyRequest(req) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`Requesting keys for ${stringifyRequestBody(req.requestBody)}` +
|
||||
` from ${stringifyRecipientList(req.recipients)}` +
|
||||
`(id ${req.requestId})`,
|
||||
@@ -347,7 +363,7 @@ export default class OutgoingRoomKeyRequestManager {
|
||||
// Given a RoomKeyRequest, cancel it and delete the request record unless
|
||||
// andResend is set, in which case transition to UNSENT.
|
||||
_sendOutgoingRoomKeyRequestCancellation(req, andResend) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`Sending cancellation for key request for ` +
|
||||
`${stringifyRequestBody(req.requestBody)} to ` +
|
||||
`${stringifyRecipientList(req.recipients)} ` +
|
||||
|
||||
@@ -71,6 +71,9 @@ export default class RoomList {
|
||||
}
|
||||
|
||||
async setRoomEncryption(roomId, roomInfo) {
|
||||
// important that this happens before calling into the store
|
||||
// as it prevents the Crypto::setRoomEncryption from calling
|
||||
// this twice for consecutive m.room.encryption events
|
||||
this._roomEncryption[roomId] = roomInfo;
|
||||
await this._cryptoStore.doTxn(
|
||||
'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -23,6 +24,7 @@ limitations under the License.
|
||||
|
||||
import Promise from 'bluebird';
|
||||
|
||||
const logger = require("../../logger");
|
||||
const utils = require("../../utils");
|
||||
const olmlib = require("../olmlib");
|
||||
const base = require("./base");
|
||||
@@ -64,7 +66,7 @@ OutboundSessionInfo.prototype.needsRotation = function(
|
||||
if (this.useCount >= rotationPeriodMsgs ||
|
||||
sessionLifetime >= rotationPeriodMs
|
||||
) {
|
||||
console.log(
|
||||
logger.log(
|
||||
"Rotating megolm session after " + this.useCount +
|
||||
" messages, " + sessionLifetime + "ms",
|
||||
);
|
||||
@@ -102,7 +104,7 @@ OutboundSessionInfo.prototype.sharedWithTooManyDevices = function(
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -112,7 +114,7 @@ OutboundSessionInfo.prototype.sharedWithTooManyDevices = function(
|
||||
}
|
||||
|
||||
if (!devicesInRoom[userId].hasOwnProperty(deviceId)) {
|
||||
console.log(
|
||||
logger.log(
|
||||
"Starting new session because we shared with " +
|
||||
userId + ":" + deviceId,
|
||||
);
|
||||
@@ -142,6 +144,11 @@ function MegolmEncryption(params) {
|
||||
// room).
|
||||
this._setupPromise = Promise.resolve();
|
||||
|
||||
// Map of outbound sessions by sessions ID. Used if we need a particular
|
||||
// session (the session we're currently using to send is always obtained
|
||||
// using _setupPromise).
|
||||
this._outboundSessions = {};
|
||||
|
||||
// default rotation periods
|
||||
this._sessionRotationPeriodMsgs = 100;
|
||||
this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000;
|
||||
@@ -181,7 +188,7 @@ MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) {
|
||||
if (session && session.needsRotation(self._sessionRotationPeriodMsgs,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -191,8 +198,9 @@ MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) {
|
||||
}
|
||||
|
||||
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();
|
||||
self._outboundSessions[session.sessionId] = session;
|
||||
}
|
||||
|
||||
// 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},
|
||||
);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -318,7 +338,7 @@ MegolmEncryption.prototype._splitUserDeviceMap = function(
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
"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 {object<string, module:crypto/deviceinfo[]>} devicesByUser
|
||||
@@ -440,10 +550,10 @@ MegolmEncryption.prototype._shareKeyWithDevices = async function(session, device
|
||||
await this._encryptAndSendKeysToDevices(
|
||||
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})`);
|
||||
} catch (e) {
|
||||
console.log(`megolm keyshare in ${this._roomId} `
|
||||
logger.log(`megolm keyshare in ${this._roomId} `
|
||||
+ `(slice ${i + 1}/${userDeviceMaps.length}) failed`);
|
||||
|
||||
throw e;
|
||||
@@ -462,7 +572,7 @@ MegolmEncryption.prototype._shareKeyWithDevices = async function(session, device
|
||||
*/
|
||||
MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) {
|
||||
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) {
|
||||
// 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,
|
||||
// Include our device ID so that recipients can send us a
|
||||
// m.new_device message if they don't have our session key.
|
||||
// XXX: Do we still need this now that m.new_device messages
|
||||
// no longer exist since #483?
|
||||
device_id: self._deviceId,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
* from userId to deviceId to deviceInfo
|
||||
*/
|
||||
MegolmEncryption.prototype._getDevicesInRoom = function(room) {
|
||||
const roomMembers = utils.map(room.getEncryptionTargetMembers(), function(u) {
|
||||
MegolmEncryption.prototype._getDevicesInRoom = async function(room) {
|
||||
const members = await room.getEncryptionTargetMembers();
|
||||
const roomMembers = utils.map(members, function(u) {
|
||||
return u.userId;
|
||||
});
|
||||
|
||||
@@ -549,35 +672,31 @@ MegolmEncryption.prototype._getDevicesInRoom = function(room) {
|
||||
// We are happy to use a cached version here: we assume that if we already
|
||||
// have a list of the user's devices, then we already share an e2e room
|
||||
// with them, which means that they will have announced any new devices via
|
||||
// an m.new_device.
|
||||
//
|
||||
// XXX: what if the cache is stale, and the user left the room we had in
|
||||
// common and then added new devices before joining this one? --Matthew
|
||||
//
|
||||
// yup, see https://github.com/vector-im/riot-web/issues/2305 --richvdh
|
||||
return this._crypto.downloadKeys(roomMembers, false).then((devices) => {
|
||||
// remove any blocked devices
|
||||
for (const userId in devices) {
|
||||
if (!devices.hasOwnProperty(userId)) {
|
||||
// device_lists in their /sync response. This cache should then be maintained
|
||||
// using all the device_lists changes and left fields.
|
||||
// See https://github.com/vector-im/riot-web/issues/2305 for details.
|
||||
const devices = await this._crypto.downloadKeys(roomMembers, false);
|
||||
// remove any blocked devices
|
||||
for (const userId in devices) {
|
||||
if (!devices.hasOwnProperty(userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userDevices = devices[userId];
|
||||
for (const deviceId in userDevices) {
|
||||
if (!userDevices.hasOwnProperty(deviceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userDevices = devices[userId];
|
||||
for (const deviceId in userDevices) {
|
||||
if (!userDevices.hasOwnProperty(deviceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (userDevices[deviceId].isBlocked() ||
|
||||
(userDevices[deviceId].isUnverified() && isBlacklisting)
|
||||
) {
|
||||
delete userDevices[deviceId];
|
||||
}
|
||||
if (userDevices[deviceId].isBlocked() ||
|
||||
(userDevices[deviceId].isUnverified() && isBlacklisting)
|
||||
) {
|
||||
delete userDevices[deviceId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return devices;
|
||||
});
|
||||
return devices;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -772,12 +891,12 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
|
||||
!sessionId ||
|
||||
!content.session_key
|
||||
) {
|
||||
console.error("key event is missing fields");
|
||||
logger.error("key event is missing fields");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!senderKey) {
|
||||
console.error("key event has no sender key (not encrypted?)");
|
||||
logger.error("key event has no sender key (not encrypted?)");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -794,13 +913,13 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
|
||||
|
||||
senderKey = content.sender_key;
|
||||
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;
|
||||
}
|
||||
|
||||
const ed25519Key = content.sender_claimed_ed25519_key;
|
||||
if (!ed25519Key) {
|
||||
console.error(
|
||||
logger.error(
|
||||
`forwarded_room_key_event is missing sender_claimed_ed25519_key field`,
|
||||
);
|
||||
return;
|
||||
@@ -813,8 +932,8 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
|
||||
keysClaimed = event.getKeysClaimed();
|
||||
}
|
||||
|
||||
console.log(`Adding key for megolm session ${senderKey}|${sessionId}`);
|
||||
this._olmDevice.addInboundGroupSession(
|
||||
logger.log(`Adding key for megolm session ${senderKey}|${sessionId}`);
|
||||
return this._olmDevice.addInboundGroupSession(
|
||||
content.room_id, senderKey, forwardingKeyChain, sessionId,
|
||||
content.session_key, keysClaimed,
|
||||
exportFormat,
|
||||
@@ -829,8 +948,21 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
|
||||
|
||||
// have another go at decrypting events sent with this session.
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
"sharing keys for session " + body.sender_key + "|"
|
||||
+ body.session_id + " with device "
|
||||
+ userId + ":" + deviceId,
|
||||
@@ -946,6 +1078,22 @@ MegolmDecryption.prototype.importRoomKey = function(session) {
|
||||
session.sender_claimed_keys,
|
||||
true,
|
||||
).then(() => {
|
||||
if (this._crypto.backupInfo) {
|
||||
// don't wait for it to complete
|
||||
this._crypto.backupGroupSession(
|
||||
session.room_id,
|
||||
session.sender_key,
|
||||
session.forwarding_curve25519_key_chain,
|
||||
session.session_id,
|
||||
session.session_key,
|
||||
session.sender_claimed_keys,
|
||||
true,
|
||||
).catch((e) => {
|
||||
// This throws if the upload failed, but this is fine
|
||||
// since it will have written it to the db and will retry.
|
||||
console.log("Failed to back up group session", e);
|
||||
});
|
||||
}
|
||||
// have another go at decrypting events sent with this session.
|
||||
this._retryDecryption(session.sender_key, session.session_id);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ limitations under the License.
|
||||
*/
|
||||
import Promise from 'bluebird';
|
||||
|
||||
const logger = require("../../logger");
|
||||
const utils = require("../../utils");
|
||||
const olmlib = require("../olmlib");
|
||||
const DeviceInfo = require("../deviceinfo");
|
||||
@@ -83,60 +84,62 @@ OlmEncryption.prototype._ensureSession = function(roomMembers) {
|
||||
*
|
||||
* @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.
|
||||
//
|
||||
// TODO: there is a race condition here! What if a new user turns up
|
||||
// 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;
|
||||
});
|
||||
|
||||
const self = this;
|
||||
return this._ensureSession(users).then(function() {
|
||||
const payloadFields = {
|
||||
room_id: room.roomId,
|
||||
type: eventType,
|
||||
content: content,
|
||||
};
|
||||
await this._ensureSession(users);
|
||||
|
||||
const encryptedContent = {
|
||||
algorithm: olmlib.OLM_ALGORITHM,
|
||||
sender_key: self._olmDevice.deviceCurve25519Key,
|
||||
ciphertext: {},
|
||||
};
|
||||
const payloadFields = {
|
||||
room_id: room.roomId,
|
||||
type: eventType,
|
||||
content: content,
|
||||
};
|
||||
|
||||
const promises = [];
|
||||
const encryptedContent = {
|
||||
algorithm: olmlib.OLM_ALGORITHM,
|
||||
sender_key: self._olmDevice.deviceCurve25519Key,
|
||||
ciphertext: {},
|
||||
};
|
||||
|
||||
for (let i = 0; i < users.length; ++i) {
|
||||
const userId = users[i];
|
||||
const devices = self._crypto.getStoredDevicesForUser(userId);
|
||||
const promises = [];
|
||||
|
||||
for (let j = 0; j < devices.length; ++j) {
|
||||
const deviceInfo = devices[j];
|
||||
const key = deviceInfo.getIdentityKey();
|
||||
if (key == self._olmDevice.deviceCurve25519Key) {
|
||||
// don't bother sending to ourself
|
||||
continue;
|
||||
}
|
||||
if (deviceInfo.verified == DeviceVerification.BLOCKED) {
|
||||
// don't bother setting up sessions with blocked users
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < users.length; ++i) {
|
||||
const userId = users[i];
|
||||
const devices = self._crypto.getStoredDevicesForUser(userId);
|
||||
|
||||
promises.push(
|
||||
olmlib.encryptMessageForDevice(
|
||||
encryptedContent.ciphertext,
|
||||
self._userId, self._deviceId, self._olmDevice,
|
||||
userId, deviceInfo, payloadFields,
|
||||
),
|
||||
);
|
||||
for (let j = 0; j < devices.length; ++j) {
|
||||
const deviceInfo = devices[j];
|
||||
const key = deviceInfo.getIdentityKey();
|
||||
if (key == self._olmDevice.deviceCurve25519Key) {
|
||||
// don't bother sending to ourself
|
||||
continue;
|
||||
}
|
||||
if (deviceInfo.verified == DeviceVerification.BLOCKED) {
|
||||
// don't bother setting up sessions with blocked users
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(promises).return(encryptedContent);
|
||||
});
|
||||
promises.push(
|
||||
olmlib.encryptMessageForDevice(
|
||||
encryptedContent.ciphertext,
|
||||
self._userId, self._deviceId, self._olmDevice,
|
||||
userId, deviceInfo, payloadFields,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return await Promise.all(promises).return(encryptedContent);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -271,7 +274,7 @@ OlmDecryption.prototype._decryptMessage = async function(
|
||||
const payload = await this._olmDevice.decryptMessage(
|
||||
theirDeviceIdentityKey, sessionId, message.type, message.body,
|
||||
);
|
||||
console.log(
|
||||
logger.log(
|
||||
"Decrypted Olm message from " + theirDeviceIdentityKey +
|
||||
" with session " + sessionId,
|
||||
);
|
||||
@@ -326,7 +329,7 @@ OlmDecryption.prototype._decryptMessage = async function(
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
"created new inbound Olm session ID " +
|
||||
res.session_id + " with " + theirDeviceIdentityKey,
|
||||
);
|
||||
|
||||
81
src/crypto/backup_password.js
Normal file
81
src/crypto/backup_password.js
Normal 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);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const anotherjson = require('another-json');
|
||||
import Promise from 'bluebird';
|
||||
import {EventEmitter} from 'events';
|
||||
|
||||
const logger = require("../logger");
|
||||
const utils = require("../utils");
|
||||
const OlmDevice = require("./OlmDevice");
|
||||
const olmlib = require("./olmlib");
|
||||
@@ -36,6 +37,12 @@ const DeviceList = require('./DeviceList').default;
|
||||
import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager';
|
||||
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
|
||||
*
|
||||
@@ -62,7 +69,7 @@ import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
|
||||
*
|
||||
* @param {RoomList} roomList An initialised RoomList object
|
||||
*/
|
||||
function Crypto(baseApis, sessionStore, userId, deviceId,
|
||||
export default function Crypto(baseApis, sessionStore, userId, deviceId,
|
||||
clientStore, cryptoStore, roomList) {
|
||||
this._baseApis = baseApis;
|
||||
this._sessionStore = sessionStore;
|
||||
@@ -72,6 +79,14 @@ function Crypto(baseApis, sessionStore, userId, deviceId,
|
||||
this._cryptoStore = cryptoStore;
|
||||
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._deviceList = new DeviceList(
|
||||
baseApis, cryptoStore, sessionStore, this._olmDevice,
|
||||
@@ -106,6 +121,24 @@ function Crypto(baseApis, sessionStore, userId, deviceId,
|
||||
this._receivedRoomKeyRequestCancellations = [];
|
||||
// true if we are currently processing received room key requests
|
||||
this._processingRoomKeyRequests = false;
|
||||
// controls whether device tracking is delayed
|
||||
// until calling encryptEvent or trackRoomDevices,
|
||||
// or done immediately upon enabling room encryption.
|
||||
this._lazyLoadMembers = false;
|
||||
// in case _lazyLoadMembers is true,
|
||||
// track if an initial tracking of all the room members
|
||||
// has happened for a given room. This is delayed
|
||||
// to avoid loading room members as long as possible.
|
||||
this._roomDeviceTrackingState = {};
|
||||
|
||||
// The timestamp of the last time we forced establishment
|
||||
// of a new session for each device, in milliseconds.
|
||||
// {
|
||||
// userId: {
|
||||
// deviceId: 1234567890000,
|
||||
// },
|
||||
// }
|
||||
this._lastNewSessionForced = {};
|
||||
}
|
||||
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.
|
||||
*/
|
||||
Crypto.prototype.init = async function() {
|
||||
await global.Olm.init();
|
||||
|
||||
const sessionStoreHasAccount = Boolean(this._sessionStore.getEndToEndAccount());
|
||||
let cryptoStoreHasAccount;
|
||||
await this._cryptoStore.doTxn(
|
||||
@@ -165,6 +200,126 @@ Crypto.prototype.init = async function() {
|
||||
);
|
||||
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 {
|
||||
crypto._onRoomMembership(event, member, oldMembership);
|
||||
} 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 */
|
||||
Crypto.prototype.stop = function() {
|
||||
this._outgoingRoomKeyRequestManager.stop();
|
||||
this._deviceList.stop();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -366,7 +522,7 @@ function _maybeUploadOneTimeKeys(crypto) {
|
||||
// create any more keys.
|
||||
return uploadLoop(keyCount);
|
||||
}).catch((e) => {
|
||||
console.error("Error uploading one-time keys", e.stack || e);
|
||||
logger.error("Error uploading one-time keys", e.stack || e);
|
||||
}).finally(() => {
|
||||
// reset _oneTimeKeyCount to prevent start uploading based on old data.
|
||||
// it will be set again on the next /sync-response
|
||||
@@ -573,7 +729,7 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) {
|
||||
// identity key of the device which set up the Megolm session.
|
||||
|
||||
const device = this._deviceList.getDeviceByIdentityKey(
|
||||
event.getSender(), algorithm, senderKey,
|
||||
algorithm, senderKey,
|
||||
);
|
||||
|
||||
if (device === null) {
|
||||
@@ -591,13 +747,13 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) {
|
||||
|
||||
const claimedKey = event.getClaimedEd25519Key();
|
||||
if (!claimedKey) {
|
||||
console.warn("Event " + event.getId() + " claims no ed25519 key: " +
|
||||
logger.warn("Event " + event.getId() + " claims no ed25519 key: " +
|
||||
"cannot verify sending device");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (claimedKey !== device.getFingerprint()) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
"Event " + event.getId() + " claims ed25519 key " + claimedKey +
|
||||
"but sender device has key " + device.getFingerprint());
|
||||
return null;
|
||||
@@ -606,6 +762,23 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) {
|
||||
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).
|
||||
*
|
||||
@@ -614,25 +787,49 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) {
|
||||
* @param {object} config The encryption config for the room.
|
||||
*
|
||||
* @param {boolean=} inhibitDeviceQuery true to suppress device list query for
|
||||
* users in the room (for now)
|
||||
* users in the room (for now). In case lazy loading is enabled,
|
||||
* the device query is always inhibited as the members are not tracked.
|
||||
*/
|
||||
Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDeviceQuery) {
|
||||
// if we already have encryption in this room, we should ignore this event
|
||||
// (for now at least. maybe we should alert the user somehow?)
|
||||
// if state is being replayed from storage, we might already have a configuration
|
||||
// for this room as they are persisted as well.
|
||||
// We just need to make sure the algorithm is initialized in this case.
|
||||
// However, if the new config is different,
|
||||
// we should bail out as room encryption can't be changed once set.
|
||||
const existingConfig = this._roomList.getRoomEncryption(roomId);
|
||||
if (existingConfig && JSON.stringify(existingConfig) != JSON.stringify(config)) {
|
||||
console.error("Ignoring m.room.encryption event which requests " +
|
||||
"a change of config in " + roomId);
|
||||
if (existingConfig) {
|
||||
if (JSON.stringify(existingConfig) != JSON.stringify(config)) {
|
||||
logger.error("Ignoring m.room.encryption event which requests " +
|
||||
"a change of config in " + roomId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// if we already have encryption in this room, we should ignore this event,
|
||||
// as it would reset the encryption algorithm.
|
||||
// This is at least expected to be called twice, as sync calls onCryptoEvent
|
||||
// for both the timeline and state sections in the /sync response,
|
||||
// the encryption event would appear in both.
|
||||
// If it's called more than twice though,
|
||||
// it signals a bug on client or server.
|
||||
const existingAlg = this._roomEncryptors[roomId];
|
||||
if (existingAlg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption
|
||||
// because it first stores in memory. We should await the promise only
|
||||
// after all the in-memory state (_roomEncryptors and _roomList) has been updated
|
||||
// to avoid races when calling this method multiple times. Hence keep a hold of the promise.
|
||||
let storeConfigPromise = null;
|
||||
if(!existingConfig) {
|
||||
storeConfigPromise = this._roomList.setRoomEncryption(roomId, config);
|
||||
}
|
||||
|
||||
const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm];
|
||||
if (!AlgClass) {
|
||||
throw new Error("Unable to encrypt with " + config.algorithm);
|
||||
}
|
||||
|
||||
await this._roomList.setRoomEncryption(roomId, config);
|
||||
|
||||
const alg = new AlgClass({
|
||||
userId: this._userId,
|
||||
deviceId: this._deviceId,
|
||||
@@ -644,24 +841,59 @@ Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDevic
|
||||
});
|
||||
this._roomEncryptors[roomId] = alg;
|
||||
|
||||
// make sure we are tracking the device lists for all users in this room.
|
||||
console.log("Enabling encryption in " + roomId + "; " +
|
||||
"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}`);
|
||||
if (storeConfigPromise) {
|
||||
await storeConfigPromise;
|
||||
}
|
||||
|
||||
const members = room.getEncryptionTargetMembers();
|
||||
members.forEach((m) => {
|
||||
this._deviceList.startTrackingDeviceList(m.userId);
|
||||
});
|
||||
if (!inhibitDeviceQuery) {
|
||||
this._deviceList.refreshOutdatedDeviceLists();
|
||||
if (!this._lazyLoadMembers) {
|
||||
logger.log("Enabling encryption in " + roomId + "; " +
|
||||
"starting to track device lists for all users therein");
|
||||
|
||||
await this.trackRoomDevices(roomId);
|
||||
// TODO: this flag is only not used from MatrixClient::setRoomEncryption
|
||||
// which is never used (inside riot at least)
|
||||
// but didn't want to remove it as it technically would
|
||||
// be a breaking change.
|
||||
if(!this.inhibitDeviceQuery) {
|
||||
this._deviceList.refreshOutdatedDeviceLists();
|
||||
}
|
||||
} else {
|
||||
logger.log("Enabling encryption in " + roomId);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Make sure we are tracking the device lists for all users in this room.
|
||||
*
|
||||
* @param {string} roomId The room ID to start tracking devices in.
|
||||
* @returns {Promise} when all devices for the room have been fetched and marked to track
|
||||
*/
|
||||
Crypto.prototype.trackRoomDevices = function(roomId) {
|
||||
const trackMembers = async () => {
|
||||
// not an encrypted room
|
||||
if (!this._roomEncryptors[roomId]) {
|
||||
return;
|
||||
}
|
||||
const room = this._clientStore.getRoom(roomId);
|
||||
if (!room) {
|
||||
throw new Error(`Unable to start tracking devices in unknown room ${roomId}`);
|
||||
}
|
||||
logger.log(`Starting to track devices for room ${roomId} ...`);
|
||||
const members = await room.getEncryptionTargetMembers();
|
||||
members.forEach((m) => {
|
||||
this._deviceList.startTrackingDeviceList(m.userId);
|
||||
});
|
||||
};
|
||||
|
||||
let promise = this._roomDeviceTrackingState[roomId];
|
||||
if (!promise) {
|
||||
promise = trackMembers();
|
||||
this._roomDeviceTrackingState[roomId] = promise;
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} module:crypto~OlmSessionResult
|
||||
* @property {module:crypto/deviceinfo} device device info
|
||||
@@ -724,6 +956,7 @@ Crypto.prototype.exportRoomKeys = async function() {
|
||||
const sess = this._olmDevice.exportInboundGroupSession(
|
||||
s.senderKey, s.sessionId, s.sessionData,
|
||||
);
|
||||
delete sess.first_known_index;
|
||||
sess.algorithm = olmlib.MEGOLM_ALGORITHM;
|
||||
exportedSessions.push(sess);
|
||||
});
|
||||
@@ -743,7 +976,7 @@ Crypto.prototype.importRoomKeys = function(keys) {
|
||||
return Promise.map(
|
||||
keys, (key) => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -763,7 +1123,8 @@ Crypto.prototype.importRoomKeys = function(keys) {
|
||||
* @return {module:client.Promise?} Promise which resolves when the event has been
|
||||
* 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) {
|
||||
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();
|
||||
// If event has an m.relates_to then we need
|
||||
// to put this on the wrapping event instead
|
||||
@@ -791,20 +1158,19 @@ Crypto.prototype.encryptEvent = function(event, room) {
|
||||
delete content['m.relates_to'];
|
||||
}
|
||||
|
||||
return alg.encryptMessage(
|
||||
room, event.getType(), content,
|
||||
).then((encryptedContent) => {
|
||||
if (mRelatesTo) {
|
||||
encryptedContent['m.relates_to'] = mRelatesTo;
|
||||
}
|
||||
const encryptedContent = await alg.encryptMessage(
|
||||
room, event.getType(), content);
|
||||
|
||||
event.makeEncrypted(
|
||||
"m.room.encrypted",
|
||||
encryptedContent,
|
||||
this._olmDevice.deviceCurve25519Key,
|
||||
this._olmDevice.deviceEd25519Key,
|
||||
);
|
||||
});
|
||||
if (mRelatesTo) {
|
||||
encryptedContent['m.relates_to'] = mRelatesTo;
|
||||
}
|
||||
|
||||
event.makeEncrypted(
|
||||
"m.room.encrypted",
|
||||
encryptedContent,
|
||||
this._olmDevice.deviceCurve25519Key,
|
||||
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
|
||||
// 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.
|
||||
this._evalDeviceListChanges(syncDeviceLists);
|
||||
await this._evalDeviceListChanges(syncDeviceLists);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -866,7 +1232,7 @@ Crypto.prototype.requestRoomKey = function(requestBody, recipients) {
|
||||
requestBody, recipients,
|
||||
).catch((e) => {
|
||||
// this normally means we couldn't talk to the store
|
||||
console.error(
|
||||
logger.error(
|
||||
'Error requesting key for event', e,
|
||||
);
|
||||
}).done();
|
||||
@@ -883,7 +1249,7 @@ Crypto.prototype.requestRoomKey = function(requestBody, recipients) {
|
||||
Crypto.prototype.cancelRoomKeyRequest = function(requestBody, andResend) {
|
||||
this._outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody, andResend)
|
||||
.catch((e) => {
|
||||
console.warn("Error clearing pending room key requests", e);
|
||||
logger.warn("Error clearing pending room key requests", e);
|
||||
}).done();
|
||||
};
|
||||
|
||||
@@ -901,7 +1267,7 @@ Crypto.prototype.onCryptoEvent = async function(event) {
|
||||
// finished processing the sync, in onSyncCompleted.
|
||||
await this.setRoomEncryption(roomId, content, true);
|
||||
} catch (e) {
|
||||
console.error("Error configuring encryption in room " + roomId +
|
||||
logger.error("Error configuring encryption in room " + roomId +
|
||||
":", e);
|
||||
}
|
||||
};
|
||||
@@ -917,8 +1283,9 @@ Crypto.prototype.onSyncWillProcess = async function(syncData) {
|
||||
// scratch, so mark everything as untracked. onCryptoEvent will
|
||||
// be called for all e2e rooms during the processing of the sync,
|
||||
// at which point we'll start tracking all the users of that room.
|
||||
console.log("Initial sync performed - resetting device tracking state");
|
||||
logger.log("Initial sync performed - resetting device tracking state");
|
||||
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
|
||||
// any more: the server isn't required to give us the
|
||||
// exact correct set.
|
||||
const e2eUserIds = new Set(this._getE2eUsers());
|
||||
const e2eUserIds = new Set(await this._getTrackedE2eUsers());
|
||||
|
||||
deviceLists.left.forEach((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
|
||||
* for which we are tracking devices already
|
||||
*
|
||||
* @returns {string[]} List of user IDs
|
||||
*/
|
||||
Crypto.prototype._getE2eUsers = function() {
|
||||
Crypto.prototype._getTrackedE2eUsers = async function() {
|
||||
const e2eUserIds = [];
|
||||
for (const room of this._getE2eRooms()) {
|
||||
const members = room.getEncryptionTargetMembers();
|
||||
for (const room of this._getTrackedE2eRooms()) {
|
||||
const members = await room.getEncryptionTargetMembers();
|
||||
for (const member of members) {
|
||||
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[]}
|
||||
*/
|
||||
Crypto.prototype._getE2eRooms = function() {
|
||||
Crypto.prototype._getTrackedE2eRooms = function() {
|
||||
return this._clientStore.getRooms().filter((room) => {
|
||||
// check for rooms with encryption enabled
|
||||
const alg = this._roomEncryptors[room.roomId];
|
||||
if (!alg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ignore any rooms which we have left
|
||||
const me = room.getMember(this._userId);
|
||||
if (!me || (
|
||||
me.membership !== "join" && me.membership !== "invite"
|
||||
)) {
|
||||
if (!this._roomDeviceTrackingState[room.roomId]) {
|
||||
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);
|
||||
} else if (event.getType() == "m.room_key_request") {
|
||||
this._onRoomKeyRequestEvent(event);
|
||||
} else if (event.getContent().msgtype === "m.bad.encrypted") {
|
||||
this._onToDeviceBadEncrypted(event);
|
||||
} else if (event.isBeingDecrypted()) {
|
||||
// once the event has been decrypted, try again
|
||||
event.once('Event.decrypted', (ev) => {
|
||||
@@ -1034,7 +1403,7 @@ Crypto.prototype._onToDeviceEvent = function(event) {
|
||||
});
|
||||
}
|
||||
} 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();
|
||||
|
||||
if (!content.room_id || !content.algorithm) {
|
||||
console.error("key event is missing fields");
|
||||
logger.error("key event is missing fields");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._checkedForBackup) {
|
||||
// don't bother awaiting on this - the important thing is that we retry if we
|
||||
// haven't managed to check before
|
||||
this._checkAndStartKeyBackup();
|
||||
}
|
||||
|
||||
const alg = this._getRoomDecryptor(content.room_id, content.algorithm);
|
||||
alg.onRoomKeyEvent(event);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a 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
|
||||
*
|
||||
@@ -1080,15 +1544,20 @@ Crypto.prototype._onRoomMembership = function(event, member, oldMembership) {
|
||||
// not encrypting in this room
|
||||
return;
|
||||
}
|
||||
|
||||
if (member.membership == 'join') {
|
||||
console.log('Join event for ' + member.userId + ' in ' + roomId);
|
||||
// make sure we are tracking the deviceList for this user
|
||||
this._deviceList.startTrackingDeviceList(member.userId);
|
||||
} else if (member.membership == 'invite' &&
|
||||
this._clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) {
|
||||
console.log('Invite event for ' + member.userId + ' in ' + roomId);
|
||||
this._deviceList.startTrackingDeviceList(member.userId);
|
||||
// only mark users in this room as tracked if we already started tracking in this room
|
||||
// this way we don't start device queries after sync on behalf of this room which we won't use
|
||||
// the result of anyway, as we'll need to do a query again once all the members are fetched
|
||||
// by calling _trackRoomDevices
|
||||
if (this._roomDeviceTrackingState[roomId]) {
|
||||
if (member.membership == 'join') {
|
||||
logger.log('Join event for ' + member.userId + ' in ' + roomId);
|
||||
// make sure we are tracking the deviceList for this user
|
||||
this._deviceList.startTrackingDeviceList(member.userId);
|
||||
} else if (member.membership == 'invite' &&
|
||||
this._clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) {
|
||||
logger.log('Invite event for ' + member.userId + ' in ' + roomId);
|
||||
this._deviceList.startTrackingDeviceList(member.userId);
|
||||
}
|
||||
}
|
||||
|
||||
alg.onRoomMembership(event, member, oldMembership);
|
||||
@@ -1153,7 +1622,7 @@ Crypto.prototype._processReceivedRoomKeyRequests = async function() {
|
||||
this._processReceivedRoomKeyRequestCancellation(cancellation),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`Error processing room key requsts: ${e}`);
|
||||
logger.error(`Error processing room key requsts: ${e}`);
|
||||
} finally {
|
||||
this._processingRoomKeyRequests = false;
|
||||
}
|
||||
@@ -1172,13 +1641,31 @@ Crypto.prototype._processReceivedRoomKeyRequest = async function(req) {
|
||||
const roomId = body.room_id;
|
||||
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})`);
|
||||
|
||||
if (userId !== this._userId) {
|
||||
// TODO: determine if we sent this device the keys already: in
|
||||
// which case we can do so again.
|
||||
console.log("Ignoring room key request from other user for now");
|
||||
if (!this._roomEncryptors[roomId]) {
|
||||
logger.debug(`room key request for unencrypted room ${roomId}`);
|
||||
return;
|
||||
}
|
||||
const encryptor = this._roomEncryptors[roomId];
|
||||
const device = this._deviceList.getStoredDevice(userId, deviceId);
|
||||
if (!device) {
|
||||
logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await encryptor.reshareKeyWithDevice(
|
||||
body.sender_key, body.session_id, userId, device,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
"Failed to re-share keys for session " + body.session_id +
|
||||
" with device " + userId + ":" + device.deviceId, e,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// the keys for the requested events, and can drop the requests.
|
||||
if (!this._roomDecryptors[roomId]) {
|
||||
console.log(`room key request for unencrypted room ${roomId}`);
|
||||
logger.log(`room key request for unencrypted room ${roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptor = this._roomDecryptors[roomId][alg];
|
||||
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;
|
||||
}
|
||||
|
||||
if (!await decryptor.hasKeysForKeyRequest(req)) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`room key request for unknown session ${roomId} / ` +
|
||||
body.session_id,
|
||||
);
|
||||
@@ -1213,7 +1700,7 @@ Crypto.prototype._processReceivedRoomKeyRequest = async function(req) {
|
||||
// if the device is is verified already, share the keys
|
||||
const device = this._deviceList.getStoredDevice(userId, deviceId);
|
||||
if (device && device.isVerified()) {
|
||||
console.log('device is already verified: sharing keys');
|
||||
logger.log('device is already verified: sharing keys');
|
||||
req.share();
|
||||
return;
|
||||
}
|
||||
@@ -1230,7 +1717,7 @@ Crypto.prototype._processReceivedRoomKeyRequest = async function(req) {
|
||||
Crypto.prototype._processReceivedRoomKeyRequestCancellation = async function(
|
||||
cancellation,
|
||||
) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`m.room_key_request cancellation for ${cancellation.userId}:` +
|
||||
`${cancellation.deviceId} (id ${cancellation.requestId})`,
|
||||
);
|
||||
@@ -1415,6 +1902,3 @@ class IncomingRoomKeyRequestCancellation {
|
||||
* @event module:client~MatrixClient#"crypto.warning"
|
||||
* @param {string} type One of the strings listed above
|
||||
*/
|
||||
|
||||
/** */
|
||||
module.exports = Crypto;
|
||||
|
||||
@@ -23,6 +23,7 @@ limitations under the License.
|
||||
import Promise from 'bluebird';
|
||||
const anotherjson = require('another-json');
|
||||
|
||||
const logger = require("../logger");
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -65,7 +71,7 @@ module.exports.encryptMessageForDevice = async function(
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
logger.log(
|
||||
"Using sessionid " + sessionId + " for device " +
|
||||
recipientUserId + ":" + recipientDevice.deviceId,
|
||||
);
|
||||
@@ -115,14 +121,17 @@ module.exports.encryptMessageForDevice = async function(
|
||||
* @param {module:base-apis~MatrixBaseApis} baseApis
|
||||
*
|
||||
* @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
|
||||
* an Object mapping from userId to deviceId to
|
||||
* {@link module:crypto~OlmSessionResult}
|
||||
*/
|
||||
module.exports.ensureOlmSessionsForDevices = async function(
|
||||
olmDevice, baseApis, devicesByUser,
|
||||
olmDevice, baseApis, devicesByUser, force,
|
||||
) {
|
||||
const devicesWithoutSession = [
|
||||
// [userId, deviceId], ...
|
||||
@@ -140,7 +149,7 @@ module.exports.ensureOlmSessionsForDevices = async function(
|
||||
const deviceId = deviceInfo.deviceId;
|
||||
const key = deviceInfo.getIdentityKey();
|
||||
const sessionId = await olmDevice.getSessionIdForDevice(key);
|
||||
if (sessionId === null) {
|
||||
if (sessionId === null || force) {
|
||||
devicesWithoutSession.push([userId, deviceId]);
|
||||
}
|
||||
result[userId][deviceId] = {
|
||||
@@ -176,7 +185,7 @@ module.exports.ensureOlmSessionsForDevices = async function(
|
||||
for (let j = 0; j < devices.length; j++) {
|
||||
const deviceInfo = devices[j];
|
||||
const deviceId = deviceInfo.deviceId;
|
||||
if (result[userId][deviceId].sessionId) {
|
||||
if (result[userId][deviceId].sessionId && !force) {
|
||||
// we already have a result for this device
|
||||
continue;
|
||||
}
|
||||
@@ -190,7 +199,7 @@ module.exports.ensureOlmSessionsForDevices = async function(
|
||||
}
|
||||
|
||||
if (!oneTimeKey) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
"No one-time keys (alg=" + oneTimeKeyAlgorithm +
|
||||
") for device " + userId + ":" + deviceId,
|
||||
);
|
||||
@@ -219,7 +228,7 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn
|
||||
deviceInfo.getFingerprint(),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
logger.error(
|
||||
"Unable to verify signature on one-time key for device " +
|
||||
userId + ":" + deviceId + ":", e,
|
||||
);
|
||||
@@ -233,12 +242,12 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn
|
||||
);
|
||||
} catch (e) {
|
||||
// possibly a bad key
|
||||
console.error("Error starting session with device " +
|
||||
logger.error("Error starting session with device " +
|
||||
userId + ":" + deviceId + ": " + e);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log("Started new sessionid " + sid +
|
||||
logger.log("Started new sessionid " + sid +
|
||||
" for device " + userId + ":" + deviceId);
|
||||
return sid;
|
||||
}
|
||||
|
||||
66
src/crypto/recoverykey.js
Normal file
66
src/crypto/recoverykey.js
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import Promise from 'bluebird';
|
||||
|
||||
import logger from '../../logger';
|
||||
import utils from '../../utils';
|
||||
|
||||
export const VERSION = 6;
|
||||
export const VERSION = 7;
|
||||
|
||||
/**
|
||||
* 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 re-create it will also block).
|
||||
db.onversionchange = (ev) => {
|
||||
console.log(`versionchange for indexeddb ${this._dbName}: closing`);
|
||||
logger.log(`versionchange for indexeddb ${this._dbName}: closing`);
|
||||
db.close();
|
||||
};
|
||||
}
|
||||
@@ -64,7 +66,7 @@ export class Backend {
|
||||
this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => {
|
||||
if (existing) {
|
||||
// this entry matches the request - return it.
|
||||
console.log(
|
||||
logger.log(
|
||||
`already have key request outstanding for ` +
|
||||
`${requestBody.room_id} / ${requestBody.session_id}: ` +
|
||||
`not sending another`,
|
||||
@@ -75,7 +77,7 @@ export class Backend {
|
||||
|
||||
// we got to the end of the list without finding a match
|
||||
// - add the new request.
|
||||
console.log(
|
||||
logger.log(
|
||||
`enqueueing key request for ${requestBody.room_id} / ` +
|
||||
requestBody.session_id,
|
||||
);
|
||||
@@ -204,6 +206,42 @@ export class Backend {
|
||||
return promiseifyTxn(txn).then(() => result);
|
||||
}
|
||||
|
||||
getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
|
||||
let stateIndex = 0;
|
||||
const results = [];
|
||||
|
||||
function onsuccess(ev) {
|
||||
const cursor = ev.target.result;
|
||||
if (cursor) {
|
||||
const keyReq = cursor.value;
|
||||
if (keyReq.recipients.includes({userId, deviceId})) {
|
||||
results.push(keyReq);
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
// try the next state in the list
|
||||
stateIndex++;
|
||||
if (stateIndex >= wantedStates.length) {
|
||||
// no matches
|
||||
return;
|
||||
}
|
||||
|
||||
const wantedState = wantedStates[stateIndex];
|
||||
const cursorReq = ev.target.source.openCursor(wantedState);
|
||||
cursorReq.onsuccess = onsuccess;
|
||||
}
|
||||
}
|
||||
|
||||
const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly");
|
||||
const store = txn.objectStore("outgoingRoomKeyRequests");
|
||||
|
||||
const wantedState = wantedStates[stateIndex];
|
||||
const cursorReq = store.index("state").openCursor(wantedState);
|
||||
cursorReq.onsuccess = onsuccess;
|
||||
|
||||
return promiseifyTxn(txn).then(() => results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for an existing room key request by id and state, and update it if
|
||||
* found
|
||||
@@ -226,7 +264,7 @@ export class Backend {
|
||||
}
|
||||
const data = cursor.value;
|
||||
if (data.state != expectedState) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`Cannot update room key request from ${expectedState} ` +
|
||||
`as it was already updated to ${data.state}`,
|
||||
);
|
||||
@@ -264,7 +302,7 @@ export class Backend {
|
||||
}
|
||||
const data = cursor.value;
|
||||
if (data.state != expectedState) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`Cannot delete room key request in state ${data.state} `
|
||||
+ `(expected ${expectedState})`,
|
||||
);
|
||||
@@ -312,7 +350,10 @@ export class Backend {
|
||||
getReq.onsuccess = function() {
|
||||
const cursor = getReq.result;
|
||||
if (cursor) {
|
||||
results[cursor.value.sessionId] = cursor.value.session;
|
||||
results[cursor.value.sessionId] = {
|
||||
session: cursor.value.session,
|
||||
lastReceivedMessageTs: cursor.value.lastReceivedMessageTs,
|
||||
};
|
||||
cursor.continue();
|
||||
} else {
|
||||
try {
|
||||
@@ -330,7 +371,10 @@ export class Backend {
|
||||
getReq.onsuccess = function() {
|
||||
try {
|
||||
if (getReq.result) {
|
||||
func(getReq.result.session);
|
||||
func({
|
||||
session: getReq.result.session,
|
||||
lastReceivedMessageTs: getReq.result.lastReceivedMessageTs,
|
||||
});
|
||||
} else {
|
||||
func(null);
|
||||
}
|
||||
@@ -340,9 +384,14 @@ export class Backend {
|
||||
};
|
||||
}
|
||||
|
||||
storeEndToEndSession(deviceKey, sessionId, session, txn) {
|
||||
storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
|
||||
const objectStore = txn.objectStore("sessions");
|
||||
objectStore.put({deviceKey, sessionId, session});
|
||||
objectStore.put({
|
||||
deviceKey,
|
||||
sessionId,
|
||||
session: sessionInfo.session,
|
||||
lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs,
|
||||
});
|
||||
}
|
||||
|
||||
// Inbound group sessions
|
||||
@@ -400,7 +449,7 @@ export class Backend {
|
||||
ev.stopPropagation();
|
||||
// ...and this stops it from aborting the transaction
|
||||
ev.preventDefault();
|
||||
console.log(
|
||||
logger.log(
|
||||
"Ignoring duplicate inbound group session: " +
|
||||
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) {
|
||||
const txn = this._db.transaction(stores, mode);
|
||||
const promise = promiseifyTxn(txn);
|
||||
@@ -471,7 +585,7 @@ export class Backend {
|
||||
}
|
||||
|
||||
export function upgradeDatabase(db, oldVersion) {
|
||||
console.log(
|
||||
logger.log(
|
||||
`Upgrading IndexedDBCryptoStore from version ${oldVersion}`
|
||||
+ ` to ${VERSION}`,
|
||||
);
|
||||
@@ -498,6 +612,11 @@ export function upgradeDatabase(db, oldVersion) {
|
||||
if (oldVersion < 6) {
|
||||
db.createObjectStore("rooms");
|
||||
}
|
||||
if (oldVersion < 7) {
|
||||
db.createObjectStore("sessions_needing_backup", {
|
||||
keyPath: ["senderCurve25519Key", "sessionId"],
|
||||
});
|
||||
}
|
||||
// Expand as needed.
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ limitations under the License.
|
||||
|
||||
import Promise from 'bluebird';
|
||||
|
||||
import logger from '../../logger';
|
||||
import LocalStorageCryptoStore from './localStorage-crypto-store';
|
||||
import MemoryCryptoStore from './memory-crypto-store';
|
||||
import * as IndexedDBCryptoStoreBackend from './indexeddb-crypto-store-backend';
|
||||
import {InvalidCryptoStoreError} from '../../errors';
|
||||
|
||||
/**
|
||||
* Internal module. indexeddb storage for e2e.
|
||||
@@ -64,7 +66,7 @@ export default class IndexedDBCryptoStore {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`connecting to indexeddb ${this._dbName}`);
|
||||
logger.log(`connecting to indexeddb ${this._dbName}`);
|
||||
|
||||
const req = this._indexedDB.open(
|
||||
this._dbName, IndexedDBCryptoStoreBackend.VERSION,
|
||||
@@ -77,7 +79,7 @@ export default class IndexedDBCryptoStore {
|
||||
};
|
||||
|
||||
req.onblocked = () => {
|
||||
console.log(
|
||||
logger.log(
|
||||
`can't yet open IndexedDBCryptoStore because it is open elsewhere`,
|
||||
);
|
||||
};
|
||||
@@ -89,20 +91,42 @@ export default class IndexedDBCryptoStore {
|
||||
req.onsuccess = (r) => {
|
||||
const db = r.target.result;
|
||||
|
||||
console.log(`connected to indexeddb ${this._dbName}`);
|
||||
logger.log(`connected to indexeddb ${this._dbName}`);
|
||||
resolve(new IndexedDBCryptoStoreBackend.Backend(db));
|
||||
};
|
||||
}).then((backend) => {
|
||||
// Edge has IndexedDB but doesn't support compund keys which we use fairly extensively.
|
||||
// Try a dummy query which will fail if the browser doesn't support compund keys, so
|
||||
// we can fall back to a different backend.
|
||||
return backend.doTxn(
|
||||
'readonly',
|
||||
[IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS],
|
||||
(txn) => {
|
||||
backend.getEndToEndInboundGroupSession('', '', txn, () => {});
|
||||
}).then(() => {
|
||||
return backend;
|
||||
},
|
||||
);
|
||||
}).catch((e) => {
|
||||
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}` +
|
||||
`: falling back to localStorage store: ${e}`,
|
||||
);
|
||||
return new LocalStorageCryptoStore(global.localStorage);
|
||||
}).catch((e) => {
|
||||
console.warn(
|
||||
`unable to open localStorage: falling back to in-memory store: ${e}`,
|
||||
);
|
||||
return new MemoryCryptoStore();
|
||||
|
||||
try {
|
||||
return new LocalStorageCryptoStore(global.localStorage);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`unable to open localStorage: falling back to in-memory store: ${e}`,
|
||||
);
|
||||
return new MemoryCryptoStore();
|
||||
}
|
||||
});
|
||||
|
||||
return this._backendPromise;
|
||||
@@ -120,11 +144,11 @@ export default class IndexedDBCryptoStore {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Removing indexeddb instance: ${this._dbName}`);
|
||||
logger.log(`Removing indexeddb instance: ${this._dbName}`);
|
||||
const req = this._indexedDB.deleteDatabase(this._dbName);
|
||||
|
||||
req.onblocked = () => {
|
||||
console.log(
|
||||
logger.log(
|
||||
`can't yet delete IndexedDBCryptoStore because it is open elsewhere`,
|
||||
);
|
||||
};
|
||||
@@ -134,14 +158,14 @@ export default class IndexedDBCryptoStore {
|
||||
};
|
||||
|
||||
req.onsuccess = () => {
|
||||
console.log(`Removed indexeddb instance: ${this._dbName}`);
|
||||
logger.log(`Removed indexeddb instance: ${this._dbName}`);
|
||||
resolve();
|
||||
};
|
||||
}).catch((e) => {
|
||||
// in firefox, with indexedDB disabled, this fails with a
|
||||
// DOMError. We treat this as non-fatal, so that people can
|
||||
// still use the app.
|
||||
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
|
||||
* found
|
||||
@@ -270,7 +312,10 @@ export default class IndexedDBCryptoStore {
|
||||
* @param {string} sessionId The ID of the session to retrieve
|
||||
* @param {*} txn An active transaction. See doTxn().
|
||||
* @param {function(object)} func Called with A map from sessionId
|
||||
* to 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) {
|
||||
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 {*} txn An active transaction. See doTxn().
|
||||
* @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) {
|
||||
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
|
||||
* @param {string} deviceKey The public key of the other device.
|
||||
* @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().
|
||||
*/
|
||||
storeEndToEndSession(deviceKey, sessionId, session, txn) {
|
||||
storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
* 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_DEVICE_DATA = 'device_data';
|
||||
IndexedDBCryptoStore.STORE_ROOMS = 'rooms';
|
||||
IndexedDBCryptoStore.STORE_BACKUP = 'sessions_needing_backup';
|
||||
|
||||
@@ -15,6 +15,8 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import Promise from 'bluebird';
|
||||
|
||||
import logger from '../../logger';
|
||||
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_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/";
|
||||
const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/";
|
||||
const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup";
|
||||
|
||||
function keyEndToEndSessions(deviceKey) {
|
||||
return E2E_PREFIX + "sessions/" + deviceKey;
|
||||
@@ -65,7 +68,21 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore {
|
||||
}
|
||||
|
||||
_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) {
|
||||
@@ -77,9 +94,9 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore {
|
||||
func(this._getEndToEndSessions(deviceKey) || {});
|
||||
}
|
||||
|
||||
storeEndToEndSession(deviceKey, sessionId, session, txn) {
|
||||
storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
|
||||
const sessions = this._getEndToEndSessions(deviceKey) || {};
|
||||
sessions[sessionId] = session;
|
||||
sessions[sessionId] = sessionInfo;
|
||||
setJsonItem(
|
||||
this.store, keyEndToEndSessions(deviceKey), sessions,
|
||||
);
|
||||
@@ -165,6 +182,58 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore {
|
||||
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.
|
||||
*
|
||||
@@ -199,8 +268,8 @@ function getJsonItem(store, key) {
|
||||
// JSON.parse(null) === null, so this returns null.
|
||||
return JSON.parse(store.getItem(key));
|
||||
} catch (e) {
|
||||
console.log("Error: Failed to get key %s: %s", key, e.stack || e);
|
||||
console.log(e.stack);
|
||||
logger.log("Error: Failed to get key %s: %s", key, e.stack || e);
|
||||
logger.log(e.stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
|
||||
import Promise from 'bluebird';
|
||||
|
||||
import logger from '../../logger';
|
||||
import utils from '../../utils';
|
||||
|
||||
/**
|
||||
@@ -41,6 +42,8 @@ export default class MemoryCryptoStore {
|
||||
this._deviceData = null;
|
||||
// roomId -> Opaque roomInfo object
|
||||
this._rooms = {};
|
||||
// Set of {senderCurve25519Key+'/'+sessionId}
|
||||
this._sessionsNeedingBackup = {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +74,7 @@ export default class MemoryCryptoStore {
|
||||
|
||||
if (existing) {
|
||||
// this entry matches the request - return it.
|
||||
console.log(
|
||||
logger.log(
|
||||
`already have key request outstanding for ` +
|
||||
`${requestBody.room_id} / ${requestBody.session_id}: ` +
|
||||
`not sending another`,
|
||||
@@ -81,7 +84,7 @@ export default class MemoryCryptoStore {
|
||||
|
||||
// we got to the end of the list without finding a match
|
||||
// - add the new request.
|
||||
console.log(
|
||||
logger.log(
|
||||
`enqueueing key request for ${requestBody.room_id} / ` +
|
||||
requestBody.session_id,
|
||||
);
|
||||
@@ -144,6 +147,19 @@ export default class MemoryCryptoStore {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) {
|
||||
const results = [];
|
||||
|
||||
for (const req of this._outgoingRoomKeyRequests) {
|
||||
for (const state of wantedStates) {
|
||||
if (req.state === state && req.recipients.includes({userId, deviceId})) {
|
||||
results.push(req);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for an existing room key request by id and state, and update it if
|
||||
* found
|
||||
@@ -163,7 +179,7 @@ export default class MemoryCryptoStore {
|
||||
}
|
||||
|
||||
if (req.state != expectedState) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`Cannot update room key request from ${expectedState} ` +
|
||||
`as it was already updated to ${req.state}`,
|
||||
);
|
||||
@@ -194,7 +210,7 @@ export default class MemoryCryptoStore {
|
||||
}
|
||||
|
||||
if (req.state != expectedState) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`Cannot delete room key request in state ${req.state} `
|
||||
+ `(expected ${expectedState})`,
|
||||
);
|
||||
@@ -233,13 +249,13 @@ export default class MemoryCryptoStore {
|
||||
func(this._sessions[deviceKey] || {});
|
||||
}
|
||||
|
||||
storeEndToEndSession(deviceKey, sessionId, session, txn) {
|
||||
storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) {
|
||||
let deviceSessions = this._sessions[deviceKey];
|
||||
if (deviceSessions === undefined) {
|
||||
deviceSessions = {};
|
||||
this._sessions[deviceKey] = deviceSessions;
|
||||
}
|
||||
deviceSessions[sessionId] = session;
|
||||
deviceSessions[sessionId] = sessionInfo;
|
||||
}
|
||||
|
||||
// Inbound Group Sessions
|
||||
@@ -295,6 +311,41 @@ export default class MemoryCryptoStore {
|
||||
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) {
|
||||
return Promise.resolve(func(null));
|
||||
}
|
||||
|
||||
46
src/errors.js
Normal file
46
src/errors.js
Normal 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);
|
||||
@@ -51,6 +51,17 @@ function Filter(userId, filterId) {
|
||||
this.definition = {};
|
||||
}
|
||||
|
||||
Filter.LAZY_LOADING_MESSAGES_FILTER = {
|
||||
lazy_load_members: true,
|
||||
};
|
||||
|
||||
Filter.LAZY_LOADING_SYNC_FILTER = {
|
||||
room: {
|
||||
state: Filter.LAZY_LOADING_MESSAGES_FILTER,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Get the ID of this filter on your homeserver (if known)
|
||||
* @return {?Number} The filter ID
|
||||
|
||||
@@ -752,6 +752,8 @@ module.exports.MatrixHttpApi.prototype = {
|
||||
method: method,
|
||||
withCredentials: false,
|
||||
qs: queryParams,
|
||||
qsStringifyOptions: opts.qsStringifyOptions,
|
||||
useQuerystring: true,
|
||||
body: data,
|
||||
json: false,
|
||||
timeout: localTimeoutMs,
|
||||
|
||||
36
src/logger.js
Normal file
36
src/logger.js
Normal 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;
|
||||
@@ -34,6 +34,8 @@ module.exports.SyncAccumulator = require("./sync-accumulator");
|
||||
module.exports.MatrixHttpApi = require("./http-api").MatrixHttpApi;
|
||||
/** The {@link module:http-api.MatrixError|MatrixError} class. */
|
||||
module.exports.MatrixError = require("./http-api").MatrixError;
|
||||
/** The {@link module:errors.InvalidStoreError|InvalidStoreError} class. */
|
||||
module.exports.InvalidStoreError = require("./errors").InvalidStoreError;
|
||||
/** The {@link module:client.MatrixClient|MatrixClient} class. */
|
||||
module.exports.MatrixClient = require("./client").MatrixClient;
|
||||
/** The {@link module:models/room|Room} class. */
|
||||
@@ -65,6 +67,8 @@ module.exports.Filter = require("./filter");
|
||||
module.exports.TimelineWindow = require("./timeline-window").TimelineWindow;
|
||||
/** The {@link module:interactive-auth} class. */
|
||||
module.exports.InteractiveAuth = require("./interactive-auth");
|
||||
/** The {@link module:auto-discovery|AutoDiscovery} class. */
|
||||
module.exports.AutoDiscovery = require("./autodiscovery").AutoDiscovery;
|
||||
|
||||
|
||||
module.exports.MemoryCryptoStore =
|
||||
|
||||
@@ -168,49 +168,19 @@ EventTimelineSet.prototype.resetLiveTimeline = function(
|
||||
// if timeline support is disabled, forget about the old timelines
|
||||
const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken;
|
||||
|
||||
let newTimeline;
|
||||
const oldTimeline = this._liveTimeline;
|
||||
const newTimeline = resetAllTimelines ?
|
||||
oldTimeline.forkLive(EventTimeline.FORWARDS) :
|
||||
oldTimeline.fork(EventTimeline.FORWARDS);
|
||||
|
||||
if (resetAllTimelines) {
|
||||
newTimeline = new EventTimeline(this);
|
||||
this._timelines = [newTimeline];
|
||||
this._eventIdToTimeline = {};
|
||||
} else {
|
||||
newTimeline = this.addTimeline();
|
||||
this._timelines.push(newTimeline);
|
||||
}
|
||||
|
||||
const oldTimeline = this._liveTimeline;
|
||||
|
||||
// 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;
|
||||
|
||||
if (forwardPaginationToken) {
|
||||
// Now set the forward pagination token on the old live timeline
|
||||
// so it can be forward-paginated.
|
||||
oldTimeline.setPaginationToken(
|
||||
|
||||
@@ -106,6 +106,50 @@ EventTimeline.prototype.initialiseState = function(stateEvents) {
|
||||
this._endState.setStateEvents(stateEvents);
|
||||
};
|
||||
|
||||
/**
|
||||
* Forks the (live) timeline, taking ownership of the existing directional state of this timeline.
|
||||
* All attached listeners will keep receiving state updates from the new live timeline state.
|
||||
* The end state of this timeline gets replaced with an independent copy of the current RoomState,
|
||||
* and will need a new pagination token if it ever needs to paginate forwards.
|
||||
|
||||
* @param {string} direction EventTimeline.BACKWARDS to get the state at the
|
||||
* start of the timeline; EventTimeline.FORWARDS to get the state at the end
|
||||
* of the timeline.
|
||||
*
|
||||
* @return {EventTimeline} the new timeline
|
||||
*/
|
||||
EventTimeline.prototype.forkLive = function(direction) {
|
||||
const forkState = this.getState(direction);
|
||||
const timeline = new EventTimeline(this._eventTimelineSet);
|
||||
timeline._startState = forkState.clone();
|
||||
// Now clobber the end state of the new live timeline with that from the
|
||||
// previous live timeline. It will be identical except that we'll keep
|
||||
// using the same RoomMember objects for the 'live' set of members with any
|
||||
// listeners still attached
|
||||
timeline._endState = forkState;
|
||||
// Firstly, we just stole the current timeline's end state, so it needs a new one.
|
||||
// Make an immutable copy of the state so back pagination will get the correct sentinels.
|
||||
this._endState = forkState.clone();
|
||||
return timeline;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an independent timeline, inheriting the directional state from this timeline.
|
||||
*
|
||||
* @param {string} direction EventTimeline.BACKWARDS to get the state at the
|
||||
* start of the timeline; EventTimeline.FORWARDS to get the state at the end
|
||||
* of the timeline.
|
||||
*
|
||||
* @return {EventTimeline} the new timeline
|
||||
*/
|
||||
EventTimeline.prototype.fork = function(direction) {
|
||||
const forkState = this.getState(direction);
|
||||
const timeline = new EventTimeline(this._eventTimelineSet);
|
||||
timeline._startState = forkState.clone();
|
||||
timeline._endState = forkState.clone();
|
||||
return timeline;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the ID of the room for this timeline
|
||||
* @return {string} room ID
|
||||
|
||||
@@ -58,10 +58,27 @@ function RoomMember(roomId, userId) {
|
||||
this.events = {
|
||||
member: null,
|
||||
};
|
||||
this._isOutOfBand = false;
|
||||
this._updateModifiedTime();
|
||||
}
|
||||
utils.inherits(RoomMember, EventEmitter);
|
||||
|
||||
/**
|
||||
* Mark the member as coming from a channel that is not sync
|
||||
*/
|
||||
RoomMember.prototype.markOutOfBand = function() {
|
||||
this._isOutOfBand = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* @return {bool} does the member come from a channel that is not sync?
|
||||
* This is used to store the member seperately
|
||||
* from the sync state so it available across browser sessions.
|
||||
*/
|
||||
RoomMember.prototype.isOutOfBand = function() {
|
||||
return this._isOutOfBand;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update this room member's membership event. May fire "RoomMember.name" if
|
||||
* this event updates this member's name.
|
||||
@@ -75,13 +92,20 @@ RoomMember.prototype.setMembershipEvent = function(event, roomState) {
|
||||
if (event.getType() !== "m.room.member") {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isOutOfBand = false;
|
||||
|
||||
this.events.member = event;
|
||||
|
||||
const oldMembership = this.membership;
|
||||
this.membership = event.getDirectionalContent().membership;
|
||||
|
||||
const oldName = this.name;
|
||||
this.name = calculateDisplayName(this, event, roomState);
|
||||
this.name = calculateDisplayName(
|
||||
this.userId,
|
||||
event.getDirectionalContent().displayname,
|
||||
roomState);
|
||||
|
||||
this.rawDisplayName = event.getDirectionalContent().displayname || this.userId;
|
||||
if (oldMembership !== this.membership) {
|
||||
this._updateModifiedTime();
|
||||
@@ -177,6 +201,44 @@ RoomMember.prototype.getLastModifiedTime = function() {
|
||||
return this._modified;
|
||||
};
|
||||
|
||||
|
||||
RoomMember.prototype.isKicked = function() {
|
||||
return this.membership === "leave" &&
|
||||
this.events.member.getSender() !== this.events.member.getStateKey();
|
||||
};
|
||||
|
||||
/**
|
||||
* If this member was invited with the is_direct flag set, return
|
||||
* the user that invited this member
|
||||
* @return {string} user id of the inviter
|
||||
*/
|
||||
RoomMember.prototype.getDMInviter = function() {
|
||||
// when not available because that room state hasn't been loaded in,
|
||||
// we don't really know, but more likely to not be a direct chat
|
||||
if (this.events.member) {
|
||||
// TODO: persist the is_direct flag on the member as more member events
|
||||
// come in caused by displayName changes.
|
||||
|
||||
// the is_direct flag is set on the invite member event.
|
||||
// This is copied on the prev_content section of the join member event
|
||||
// when the invite is accepted.
|
||||
|
||||
const memberEvent = this.events.member;
|
||||
let memberContent = memberEvent.getContent();
|
||||
let inviteSender = memberEvent.getSender();
|
||||
|
||||
if (memberContent.membership === "join") {
|
||||
memberContent = memberEvent.getPrevContent();
|
||||
inviteSender = memberEvent.getUnsigned().prev_sender;
|
||||
}
|
||||
|
||||
if (memberContent.membership === "invite" && memberContent.is_direct) {
|
||||
return inviteSender;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Get the avatar URL for a room member.
|
||||
* @param {string} baseUrl The base homeserver URL See
|
||||
@@ -200,10 +262,12 @@ RoomMember.prototype.getAvatarUrl =
|
||||
if (allowDefault === undefined) {
|
||||
allowDefault = true;
|
||||
}
|
||||
if (!this.events.member && !allowDefault) {
|
||||
|
||||
const rawUrl = this.getMxcAvatarUrl();
|
||||
|
||||
if (!rawUrl && !allowDefault) {
|
||||
return null;
|
||||
}
|
||||
const rawUrl = this.events.member ? this.events.member.getContent().avatar_url : null;
|
||||
const httpUrl = ContentRepo.getHttpUriForMxc(
|
||||
baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks,
|
||||
);
|
||||
@@ -216,12 +280,21 @@ RoomMember.prototype.getAvatarUrl =
|
||||
}
|
||||
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) {
|
||||
const displayName = event.getDirectionalContent().displayname;
|
||||
const selfUserId = member.userId;
|
||||
|
||||
if (!displayName) {
|
||||
function calculateDisplayName(selfUserId, displayName, roomState) {
|
||||
if (!displayName || displayName === selfUserId) {
|
||||
return selfUserId;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ const EventEmitter = require("events").EventEmitter;
|
||||
const utils = require("../utils");
|
||||
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.
|
||||
*
|
||||
@@ -46,13 +51,17 @@ const RoomMember = require("./room-member");
|
||||
* @constructor
|
||||
* @param {?string} roomId Optional. The ID of the room which has this state.
|
||||
* If none is specified it just tracks paginationTokens, useful for notifTimelineSet
|
||||
* @param {?object} oobMemberFlags Optional. The state of loading out of bound members.
|
||||
* As the timeline might get reset while they are loading, this state needs to be inherited
|
||||
* and shared when the room state is cloned for the new timeline.
|
||||
* This should only be passed from clone.
|
||||
* @prop {Object.<string, RoomMember>} members The room member dictionary, keyed
|
||||
* on the user's ID.
|
||||
* @prop {Object.<string, Object.<string, MatrixEvent>>} events The state
|
||||
* events dictionary, keyed on the event type and then the state_key value.
|
||||
* @prop {string} paginationToken The pagination token for this state.
|
||||
*/
|
||||
function RoomState(roomId) {
|
||||
function RoomState(roomId, oobMemberFlags = undefined) {
|
||||
this.roomId = roomId;
|
||||
this.members = {
|
||||
// userId: RoomMember
|
||||
@@ -70,6 +79,22 @@ function RoomState(roomId) {
|
||||
this._userIdsToDisplayNames = {};
|
||||
this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite
|
||||
this._joinedMemberCount = null; // cache of the number of joined members
|
||||
// joined members count from summary api
|
||||
// once set, we know the server supports the summary api
|
||||
// and we should only trust that
|
||||
// we could also only trust that before OOB members
|
||||
// are loaded but doesn't seem worth the hassle atm
|
||||
this._summaryJoinedMemberCount = null;
|
||||
// same for invited member count
|
||||
this._invitedMemberCount = null;
|
||||
this._summaryInvitedMemberCount = null;
|
||||
|
||||
if (!oobMemberFlags) {
|
||||
oobMemberFlags = {
|
||||
status: OOB_STATUS_NOTSTARTED,
|
||||
};
|
||||
}
|
||||
this._oobMemberFlags = oobMemberFlags;
|
||||
}
|
||||
utils.inherits(RoomState, EventEmitter);
|
||||
|
||||
@@ -79,14 +104,48 @@ utils.inherits(RoomState, EventEmitter);
|
||||
* @return {integer} The number of members in this room whose membership is 'join'
|
||||
*/
|
||||
RoomState.prototype.getJoinedMemberCount = function() {
|
||||
if (this._summaryJoinedMemberCount !== null) {
|
||||
return this._summaryJoinedMemberCount;
|
||||
}
|
||||
if (this._joinedMemberCount === null) {
|
||||
this._joinedMemberCount = this.getMembers().filter((m) => {
|
||||
return m.membership === 'join';
|
||||
}).length;
|
||||
this._joinedMemberCount = this.getMembers().reduce((count, m) => {
|
||||
return m.membership === 'join' ? count + 1 : count;
|
||||
}, 0);
|
||||
}
|
||||
return this._joinedMemberCount;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the joined member count explicitly (like from summary part of the sync response)
|
||||
* @param {number} count the amount of joined members
|
||||
*/
|
||||
RoomState.prototype.setJoinedMemberCount = function(count) {
|
||||
this._summaryJoinedMemberCount = count;
|
||||
};
|
||||
/**
|
||||
* Returns the number of invited members in this room
|
||||
* @return {integer} The number of members in this room whose membership is 'invite'
|
||||
*/
|
||||
RoomState.prototype.getInvitedMemberCount = function() {
|
||||
if (this._summaryInvitedMemberCount !== null) {
|
||||
return this._summaryInvitedMemberCount;
|
||||
}
|
||||
if (this._invitedMemberCount === null) {
|
||||
this._invitedMemberCount = this.getMembers().reduce((count, m) => {
|
||||
return m.membership === 'invite' ? count + 1 : count;
|
||||
}, 0);
|
||||
}
|
||||
return this._invitedMemberCount;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the amount of invited members in this room
|
||||
* @param {number} count the amount of invited members
|
||||
*/
|
||||
RoomState.prototype.setInvitedMemberCount = function(count) {
|
||||
this._summaryInvitedMemberCount = count;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all RoomMembers in this room.
|
||||
* @return {Array<RoomMember>} A list of RoomMembers.
|
||||
@@ -95,6 +154,16 @@ RoomState.prototype.getMembers = function() {
|
||||
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.
|
||||
* @param {string} userId The room member's user ID.
|
||||
@@ -119,12 +188,9 @@ RoomState.prototype.getSentinelMember = function(userId) {
|
||||
|
||||
if (sentinel === undefined) {
|
||||
sentinel = new RoomMember(this.roomId, userId);
|
||||
const membershipEvent = this.getStateEvents("m.room.member", userId);
|
||||
if (!membershipEvent) return null;
|
||||
sentinel.setMembershipEvent(membershipEvent, this);
|
||||
const pwrLvlEvent = this.getStateEvents("m.room.power_levels", "");
|
||||
if (pwrLvlEvent) {
|
||||
sentinel.setPowerLevelEvent(pwrLvlEvent);
|
||||
const member = this.members[userId];
|
||||
if (member) {
|
||||
sentinel.setMembershipEvent(member.events.member, this);
|
||||
}
|
||||
this._sentinels[userId] = sentinel;
|
||||
}
|
||||
@@ -152,6 +218,67 @@ RoomState.prototype.getStateEvents = function(eventType, stateKey) {
|
||||
return event ? event : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a copy of this room state so that mutations to either won't affect the other.
|
||||
* @return {RoomState} the copy of the room state
|
||||
*/
|
||||
RoomState.prototype.clone = function() {
|
||||
const copy = new RoomState(this.roomId, this._oobMemberFlags);
|
||||
|
||||
// Ugly hack: because setStateEvents will mark
|
||||
// members as susperseding future out of bound members
|
||||
// if loading is in progress (through _oobMemberFlags)
|
||||
// since these are not new members, we're merely copying them
|
||||
// set the status to not started
|
||||
// after copying, we set back the status
|
||||
const status = this._oobMemberFlags.status;
|
||||
this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED;
|
||||
|
||||
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
|
||||
* any existing state with the same {type, stateKey} tuple. Will fire
|
||||
@@ -175,10 +302,7 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.events[event.getType()] === undefined) {
|
||||
self.events[event.getType()] = {};
|
||||
}
|
||||
self.events[event.getType()][event.getStateKey()] = event;
|
||||
self._setStateEvent(event);
|
||||
if (event.getType() === "m.room.member") {
|
||||
_updateDisplayNameCache(
|
||||
self, event.getStateKey(), event.getContent().displayname,
|
||||
@@ -216,24 +340,10 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
|
||||
event.getPrevContent().displayname;
|
||||
}
|
||||
|
||||
let member = self.members[userId];
|
||||
if (!member) {
|
||||
member = new RoomMember(event.getRoomId(), userId);
|
||||
self.emit("RoomState.newMember", event, self, member);
|
||||
}
|
||||
|
||||
const member = self._getOrCreateMember(userId, event);
|
||||
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
|
||||
delete self._sentinels[userId];
|
||||
|
||||
self.members[userId] = member;
|
||||
self._joinedMemberCount = null;
|
||||
self._updateMember(member);
|
||||
self.emit("RoomState.members", event, self, member);
|
||||
} else if (event.getType() === "m.room.power_levels") {
|
||||
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.
|
||||
* @param {MatrixEvent} event The typing event
|
||||
@@ -401,11 +645,6 @@ RoomState.prototype.maySendStateEvent = function(stateEventType, userId) {
|
||||
* according to the room's 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', '');
|
||||
|
||||
let power_levels;
|
||||
@@ -413,25 +652,34 @@ RoomState.prototype._maySendEventOfType = function(eventType, userId, state) {
|
||||
|
||||
let state_default = 0;
|
||||
let events_default = 0;
|
||||
let powerLevel = 0;
|
||||
if (power_levels_event) {
|
||||
power_levels = power_levels_event.getContent();
|
||||
events_levels = power_levels.events || {};
|
||||
|
||||
if (utils.isNumber(power_levels.state_default)) {
|
||||
if (Number.isFinite(power_levels.state_default)) {
|
||||
state_default = power_levels.state_default;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
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
|
||||
* 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"
|
||||
* @param {MatrixEvent} event The matrix event which caused this event to fire.
|
||||
* @param {RoomState} state The room state whose RoomState.members dictionary
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -21,6 +22,7 @@ const EventEmitter = require("events").EventEmitter;
|
||||
|
||||
const EventStatus = require("./event").EventStatus;
|
||||
const RoomSummary = require("./room-summary");
|
||||
const RoomMember = require("./room-member");
|
||||
const MatrixEvent = require("./event").MatrixEvent;
|
||||
const utils = require("../utils");
|
||||
const ContentRepo = require("../content-repo");
|
||||
@@ -29,6 +31,8 @@ const EventTimelineSet = require("./event-timeline-set");
|
||||
|
||||
import ReEmitter from '../ReEmitter';
|
||||
|
||||
const LATEST_ROOM_VERSION = '1';
|
||||
|
||||
function synthesizeReceipt(userId, event, receiptType) {
|
||||
// console.log("synthesizing receipt for "+event.getId());
|
||||
// This is really ugly because JS has no way to express an object literal
|
||||
@@ -68,6 +72,8 @@ function synthesizeReceipt(userId, event, receiptType) {
|
||||
* @constructor
|
||||
* @alias module:models/room
|
||||
* @param {string} roomId Required. The ID of this room.
|
||||
* @param {MatrixClient} client Required. The client, used to lazy load members.
|
||||
* @param {string} myUserId Required. The ID of the syncing user.
|
||||
* @param {Object=} opts Configuration options
|
||||
* @param {*} opts.storageToken Optional. The token which a data store can use
|
||||
* to remember the state of the room. What this means is dependent on the store
|
||||
@@ -102,7 +108,7 @@ function synthesizeReceipt(userId, event, receiptType) {
|
||||
* @prop {*} storageToken A token which a data store can use to remember
|
||||
* the state of the room.
|
||||
*/
|
||||
function Room(roomId, opts) {
|
||||
function Room(roomId, client, myUserId, opts) {
|
||||
opts = opts || {};
|
||||
opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological";
|
||||
|
||||
@@ -115,6 +121,7 @@ function Room(roomId, opts) {
|
||||
);
|
||||
}
|
||||
|
||||
this.myUserId = myUserId;
|
||||
this.roomId = roomId;
|
||||
this.name = roomId;
|
||||
this.tags = {
|
||||
@@ -171,9 +178,56 @@ function Room(roomId, opts) {
|
||||
|
||||
// read by megolm; boolean value - null indicates "use global value"
|
||||
this._blacklistUnverifiedDevices = null;
|
||||
this._selfMembership = null;
|
||||
this._summaryHeroes = null;
|
||||
// awaited by getEncryptionTargetMembers while room members are loading
|
||||
|
||||
this._client = client;
|
||||
if (!this._opts.lazyLoadMembers) {
|
||||
this._membersPromise = Promise.resolve();
|
||||
} else {
|
||||
this._membersPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
utils.inherits(Room, EventEmitter);
|
||||
|
||||
/**
|
||||
* Gets the version of the room
|
||||
* @returns {string} The version of the room, or null if it could not be determined
|
||||
*/
|
||||
Room.prototype.getVersion = function() {
|
||||
const createEvent = this.currentState.getStateEvents("m.room.create", "");
|
||||
if (!createEvent) {
|
||||
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
|
||||
*
|
||||
@@ -201,6 +255,232 @@ Room.prototype.getLiveTimeline = function() {
|
||||
return this.getUnfilteredTimelineSet().getLiveTimeline();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} myUserId the user id for the logged in member
|
||||
* @return {string} the membership type (join | leave | invite) for the logged in user
|
||||
*/
|
||||
Room.prototype.getMyMembership = function() {
|
||||
return this._selfMembership;
|
||||
};
|
||||
|
||||
/**
|
||||
* If this room is a DM we're invited to,
|
||||
* try to find out who invited us
|
||||
* @return {string} user id of the inviter
|
||||
*/
|
||||
Room.prototype.getDMInviter = function() {
|
||||
if (this.myUserId) {
|
||||
const me = this.getMember(this.myUserId);
|
||||
if (me) {
|
||||
return me.getDMInviter();
|
||||
}
|
||||
}
|
||||
if (this._selfMembership === "invite") {
|
||||
// fall back to summary information
|
||||
const memberCount = this.getInvitedAndJoinedMemberCount();
|
||||
if (memberCount == 2 && this._summaryHeroes.length) {
|
||||
return this._summaryHeroes[0];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Assuming this room is a DM room, tries to guess with which user.
|
||||
* @return {string} user id of the other member (could be syncing user)
|
||||
*/
|
||||
Room.prototype.guessDMUserId = function() {
|
||||
const me = this.getMember(this.myUserId);
|
||||
if (me) {
|
||||
const inviterId = me.getDMInviter();
|
||||
if (inviterId) {
|
||||
return inviterId;
|
||||
}
|
||||
}
|
||||
// remember, we're assuming this room is a DM,
|
||||
// so returning the first member we find should be fine
|
||||
const hasHeroes = Array.isArray(this._summaryHeroes) &&
|
||||
this._summaryHeroes.length;
|
||||
if (hasHeroes) {
|
||||
return this._summaryHeroes[0];
|
||||
}
|
||||
const members = this.currentState.getMembers();
|
||||
const anyMember = members.find((m) => m.userId !== this.myUserId);
|
||||
if (anyMember) {
|
||||
return anyMember.userId;
|
||||
}
|
||||
// it really seems like I'm the only user in the room
|
||||
// so I probably created a room with just me in it
|
||||
// and marked it as a DM. Ok then
|
||||
return this.myUserId;
|
||||
};
|
||||
|
||||
Room.prototype.getAvatarFallbackMember = function() {
|
||||
const memberCount = this.getInvitedAndJoinedMemberCount();
|
||||
if (memberCount > 2) {
|
||||
return;
|
||||
}
|
||||
const hasHeroes = Array.isArray(this._summaryHeroes) &&
|
||||
this._summaryHeroes.length;
|
||||
if (hasHeroes) {
|
||||
const availableMember = this._summaryHeroes.map((userId) => {
|
||||
return this.getMember(userId);
|
||||
}).find((member) => !!member);
|
||||
if (availableMember) {
|
||||
return availableMember;
|
||||
}
|
||||
}
|
||||
const members = this.currentState.getMembers();
|
||||
// could be different than memberCount
|
||||
// as this includes left members
|
||||
if (members.length <= 2) {
|
||||
const availableMember = members.find((m) => {
|
||||
return m.userId !== this.myUserId;
|
||||
});
|
||||
if (availableMember) {
|
||||
return availableMember;
|
||||
}
|
||||
}
|
||||
// if all else fails, try falling back to a user,
|
||||
// and create a one-off member for it
|
||||
if (hasHeroes) {
|
||||
const availableUser = this._summaryHeroes.map((userId) => {
|
||||
return this._client.getUser(userId);
|
||||
}).find((user) => !!user);
|
||||
if (availableUser) {
|
||||
const member = new RoomMember(
|
||||
this.roomId, availableUser.userId);
|
||||
member.user = availableUser;
|
||||
return member;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the membership this room was received as during sync
|
||||
* @param {string} membership join | leave | invite
|
||||
*/
|
||||
Room.prototype.updateMyMembership = function(membership) {
|
||||
const prevMembership = this._selfMembership;
|
||||
this._selfMembership = membership;
|
||||
if (prevMembership !== membership) {
|
||||
if (membership === "leave") {
|
||||
this._cleanupAfterLeaving();
|
||||
}
|
||||
this.emit("Room.myMembership", this, membership, prevMembership);
|
||||
}
|
||||
};
|
||||
|
||||
Room.prototype._loadMembersFromServer = async function() {
|
||||
const lastSyncToken = this._client.store.getSyncToken();
|
||||
const queryString = utils.encodeParams({
|
||||
not_membership: "leave",
|
||||
at: lastSyncToken,
|
||||
});
|
||||
const path = utils.encodeUri("/rooms/$roomId/members?" + queryString,
|
||||
{$roomId: this.roomId});
|
||||
const http = this._client._http;
|
||||
const response = await http.authedRequest(undefined, "GET", path);
|
||||
return response.chunk;
|
||||
};
|
||||
|
||||
|
||||
Room.prototype._loadMembers = async function() {
|
||||
// were the members loaded from the server?
|
||||
let fromServer = false;
|
||||
let rawMembersEvents =
|
||||
await this._client.store.getOutOfBandMembers(this.roomId);
|
||||
if (rawMembersEvents === null) {
|
||||
fromServer = true;
|
||||
rawMembersEvents = await this._loadMembersFromServer();
|
||||
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.
|
||||
@@ -306,6 +586,26 @@ Room.prototype.setUnreadNotificationCount = function(type, count) {
|
||||
this._notificationCounts[type] = count;
|
||||
};
|
||||
|
||||
Room.prototype.setSummary = function(summary) {
|
||||
const heroes = summary["m.heroes"];
|
||||
const joinedCount = summary["m.joined_member_count"];
|
||||
const invitedCount = summary["m.invited_member_count"];
|
||||
if (Number.isInteger(joinedCount)) {
|
||||
this.currentState.setJoinedMemberCount(joinedCount);
|
||||
}
|
||||
if (Number.isInteger(invitedCount)) {
|
||||
this.currentState.setInvitedMemberCount(invitedCount);
|
||||
}
|
||||
if (Array.isArray(heroes)) {
|
||||
// be cautious about trusting server values,
|
||||
// and make sure heroes doesn't contain our own id
|
||||
// just to be sure
|
||||
this._summaryHeroes = heroes.filter((userId) => {
|
||||
return userId !== this.myUserId;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether to send encrypted messages to devices within this room.
|
||||
* @param {Boolean} value true to blacklist unverified devices, null
|
||||
@@ -430,11 +730,7 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
|
||||
* @return {RoomMember} The member or <code>null</code>.
|
||||
*/
|
||||
Room.prototype.getMember = function(userId) {
|
||||
const member = this.currentState.members[userId];
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
return member;
|
||||
return this.currentState.getMember(userId);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -445,6 +741,33 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
|
||||
return this.getMembersWithMembership("join");
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the number of joined members in this room
|
||||
* This method caches the result.
|
||||
* This is a wrapper around the method of the same name in roomState, returning
|
||||
* its result for the room's current state.
|
||||
* @return {integer} The number of members in this room whose membership is 'join'
|
||||
*/
|
||||
Room.prototype.getJoinedMemberCount = function() {
|
||||
return this.currentState.getJoinedMemberCount();
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the number of invited members in this room
|
||||
* @return {integer} The number of members in this room whose membership is 'invite'
|
||||
*/
|
||||
Room.prototype.getInvitedMemberCount = function() {
|
||||
return this.currentState.getInvitedMemberCount();
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the number of invited + joined members in this room
|
||||
* @return {integer} The number of members in this room whose membership is 'invite' or 'join'
|
||||
*/
|
||||
Room.prototype.getInvitedAndJoinedMemberCount = function() {
|
||||
return this.getInvitedMemberCount() + this.getJoinedMemberCount();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a list of members with given membership state.
|
||||
* @param {string} membership The membership state.
|
||||
@@ -458,10 +781,11 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
|
||||
|
||||
/**
|
||||
* 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
|
||||
* in this room.
|
||||
* @return {Promise<RoomMember[]>} A list of members who
|
||||
* 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");
|
||||
if (this.shouldEncryptForInvitedMembers()) {
|
||||
members = members.concat(this.getMembersWithMembership("invite"));
|
||||
@@ -675,6 +999,10 @@ Room.prototype.addPendingEvent = function(event, txnId) {
|
||||
this._txnToEvent[txnId] = event;
|
||||
|
||||
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);
|
||||
} else {
|
||||
for (let i = 0; i < this._timelineSets.length; i++) {
|
||||
@@ -839,7 +1167,7 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) {
|
||||
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
|
||||
* room summary. Call this any time the room's current state is modified.
|
||||
* 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"
|
||||
*/
|
||||
Room.prototype.recalculate = function(userId) {
|
||||
Room.prototype.recalculate = function() {
|
||||
// set fake stripped state events if this is an invite room so logic remains
|
||||
// consistent elsewhere.
|
||||
const self = this;
|
||||
const membershipEvent = this.currentState.getStateEvents(
|
||||
"m.room.member", userId,
|
||||
"m.room.member", this.myUserId,
|
||||
);
|
||||
if (membershipEvent && membershipEvent.getContent().membership === "invite") {
|
||||
const strippedStateEvents = membershipEvent.event.invite_room_state || [];
|
||||
@@ -955,14 +1282,14 @@ Room.prototype.recalculate = function(userId) {
|
||||
content: strippedEvent.content,
|
||||
event_id: "$fake" + Date.now(),
|
||||
room_id: self.roomId,
|
||||
user_id: userId, // technically a lie
|
||||
user_id: self.myUserId, // technically a lie
|
||||
})]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const oldName = this.name;
|
||||
this.name = calculateRoomName(this, userId);
|
||||
this.name = calculateRoomName(this, this.myUserId);
|
||||
this.summary = new RoomSummary(this.roomId, {
|
||||
title: this.name,
|
||||
});
|
||||
@@ -1143,7 +1470,7 @@ Room.prototype.addTags = function(event) {
|
||||
// }
|
||||
|
||||
// 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
|
||||
// changed - but do we want to bother?
|
||||
@@ -1174,6 +1501,17 @@ Room.prototype.getAccountData = function(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
|
||||
* room state.
|
||||
@@ -1207,87 +1545,83 @@ function calculateRoomName(room, userId, ignoreRoomNameEvent) {
|
||||
return alias;
|
||||
}
|
||||
|
||||
// get members that are NOT ourselves and are actually in the room.
|
||||
const otherMembers = utils.filter(room.currentState.getMembers(), function(m) {
|
||||
return (
|
||||
m.userId !== userId && m.membership !== "leave" && m.membership !== "ban"
|
||||
);
|
||||
});
|
||||
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
|
||||
);
|
||||
const joinedMemberCount = room.currentState.getJoinedMemberCount();
|
||||
const invitedMemberCount = room.currentState.getInvitedMemberCount();
|
||||
// -1 because these numbers include the syncing user
|
||||
const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1;
|
||||
|
||||
// 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 {
|
||||
return "Room Invite";
|
||||
}
|
||||
// get members that are NOT ourselves and are actually in the room.
|
||||
let otherNames = null;
|
||||
if (room._summaryHeroes) {
|
||||
// if we have a summary, the member state events
|
||||
// should be in the room state
|
||||
otherNames = room._summaryHeroes.map((userId) => {
|
||||
const member = room.getMember(userId);
|
||||
return member ? member.name : userId;
|
||||
});
|
||||
} else {
|
||||
let otherMembers = room.currentState.getMembers().filter((m) => {
|
||||
return m.userId !== userId &&
|
||||
(m.membership === "invite" || m.membership === "join");
|
||||
});
|
||||
// make sure members have stable order
|
||||
otherMembers.sort((a, b) => a.userId.localeCompare(b.userId));
|
||||
// only 5 first members, immitate _summaryHeroes
|
||||
otherMembers = otherMembers.slice(0, 5);
|
||||
otherNames = otherMembers.map((m) => m.name);
|
||||
}
|
||||
|
||||
if (inviteJoinCount) {
|
||||
return memberNamesToRoomName(otherNames, inviteJoinCount);
|
||||
}
|
||||
|
||||
if (otherMembers.length === 0) {
|
||||
const leftMembers = utils.filter(room.currentState.getMembers(), function(m) {
|
||||
return m.userId !== userId && m.membership === "leave";
|
||||
});
|
||||
if (allMembers.length === 1) {
|
||||
// self-chat, peeked room with 1 participant,
|
||||
// or inbound invite, or outbound 3PID invite.
|
||||
if (allMembers[0].userId === userId) {
|
||||
const thirdPartyInvites =
|
||||
room.currentState.getStateEvents("m.room.third_party_invite");
|
||||
if (thirdPartyInvites && thirdPartyInvites.length > 0) {
|
||||
let name = "Inviting " +
|
||||
thirdPartyInvites[0].getContent().display_name;
|
||||
if (thirdPartyInvites.length > 1) {
|
||||
if (thirdPartyInvites.length == 2) {
|
||||
name += " and " +
|
||||
thirdPartyInvites[1].getContent().display_name;
|
||||
} else {
|
||||
name += " and " +
|
||||
thirdPartyInvites.length + " others";
|
||||
}
|
||||
}
|
||||
return name;
|
||||
} else if (leftMembers.length === 1) {
|
||||
// if it was a chat with one person who's now left, it's still
|
||||
// notionally a chat with them
|
||||
return leftMembers[0].name;
|
||||
} else {
|
||||
return "Empty room";
|
||||
}
|
||||
} else {
|
||||
return allMembers[0].name;
|
||||
}
|
||||
} else {
|
||||
// there really isn't anyone in this room...
|
||||
return "Empty room";
|
||||
const myMembership = room.getMyMembership();
|
||||
// if I have created a room and invited people throuh
|
||||
// 3rd party invites
|
||||
if (myMembership == 'join') {
|
||||
const thirdPartyInvites =
|
||||
room.currentState.getStateEvents("m.room.third_party_invite");
|
||||
|
||||
if (thirdPartyInvites && thirdPartyInvites.length) {
|
||||
const thirdPartyNames = thirdPartyInvites.map((i) => {
|
||||
return i.getContent().display_name;
|
||||
});
|
||||
|
||||
return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`;
|
||||
}
|
||||
} else if (otherMembers.length === 1) {
|
||||
return otherMembers[0].name;
|
||||
} else if (otherMembers.length === 2) {
|
||||
return (
|
||||
otherMembers[0].name + " and " + otherMembers[1].name
|
||||
);
|
||||
}
|
||||
// let's try to figure out who was here before
|
||||
let leftNames = otherNames;
|
||||
// if we didn't have heroes, try finding them in the room state
|
||||
if(!leftNames.length) {
|
||||
leftNames = room.currentState.getMembers().filter((m) => {
|
||||
return m.userId !== userId &&
|
||||
m.membership !== "invite" &&
|
||||
m.membership !== "join";
|
||||
}).map((m) => m.name);
|
||||
}
|
||||
if(leftNames.length) {
|
||||
return `Empty room (was ${memberNamesToRoomName(leftNames)})`;
|
||||
} else {
|
||||
return (
|
||||
otherMembers[0].name + " and " + (otherMembers.length - 1) + " others"
|
||||
);
|
||||
return "Empty room";
|
||||
}
|
||||
}
|
||||
|
||||
function memberNamesToRoomName(names, count = (names.length + 1)) {
|
||||
const countWithoutMe = count - 1;
|
||||
if (!names.length) {
|
||||
return "Empty room";
|
||||
} else if (names.length === 1 && countWithoutMe <= 1) {
|
||||
return names[0];
|
||||
} else if (names.length === 2 && countWithoutMe <= 2) {
|
||||
return `${names[0]} and ${names[1]}`;
|
||||
} else {
|
||||
const plural = countWithoutMe > 1;
|
||||
if (plural) {
|
||||
return `${names[0]} and ${countWithoutMe} others`;
|
||||
} else {
|
||||
return `${names[0]} and 1 other`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ limitations under the License.
|
||||
* when a user was last active.
|
||||
* @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be
|
||||
* an approximation and that the user should be seen as active 'now'
|
||||
* @prop {string} _unstable_statusMessage The status message for the user, if known. This is
|
||||
* different from the presenceStatusMsg in that this is not tied to
|
||||
* the user's presence, and should be represented differently.
|
||||
* @prop {Object} events The events describing this user.
|
||||
* @prop {MatrixEvent} events.presence The m.presence event for this user.
|
||||
*/
|
||||
@@ -46,6 +49,7 @@ function User(userId) {
|
||||
this.userId = userId;
|
||||
this.presence = "offline";
|
||||
this.presenceStatusMsg = null;
|
||||
this._unstable_statusMessage = "";
|
||||
this.displayName = userId;
|
||||
this.rawDisplayName = userId;
|
||||
this.avatarUrl = null;
|
||||
@@ -179,6 +183,16 @@ User.prototype.getLastActiveTs = function() {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {escapeRegExp, globToRegexp} from "./utils";
|
||||
|
||||
/**
|
||||
* @module pushprocessor
|
||||
*/
|
||||
@@ -26,10 +29,6 @@ const RULEKINDS_IN_ORDER = ['override', 'content', 'room', 'sender', 'underride'
|
||||
* @param {Object} client The Matrix client object to use
|
||||
*/
|
||||
function PushProcessor(client) {
|
||||
const escapeRegExp = function(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
};
|
||||
|
||||
const cachedGlobToRegex = {
|
||||
// $glob: RegExp,
|
||||
};
|
||||
@@ -244,22 +243,6 @@ function PushProcessor(client) {
|
||||
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 parts = key.split('.');
|
||||
let val;
|
||||
|
||||
26
src/randomstring.js
Normal file
26
src/randomstring.js
Normal 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;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import Promise from 'bluebird';
|
||||
import SyncAccumulator from "../sync-accumulator";
|
||||
import utils from "../utils";
|
||||
|
||||
const VERSION = 1;
|
||||
const VERSION = 3;
|
||||
|
||||
function createDatabase(db) {
|
||||
// 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"] });
|
||||
}
|
||||
|
||||
function upgradeSchemaV2(db) {
|
||||
const oobMembersStore = db.createObjectStore(
|
||||
"oob_membership_events", {
|
||||
keyPath: ["room_id", "state_key"],
|
||||
});
|
||||
oobMembersStore.createIndex("room", "room_id");
|
||||
}
|
||||
|
||||
function upgradeSchemaV3(db) {
|
||||
db.createObjectStore("client_options",
|
||||
{ keyPath: ["clobber"]});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper method to collect results from a Cursor and promiseify it.
|
||||
* @param {ObjectStore|Index} store The store to perform openCursor on.
|
||||
@@ -63,28 +77,39 @@ function selectQuery(store, keyRange, resultMapper) {
|
||||
});
|
||||
}
|
||||
|
||||
function promiseifyTxn(txn) {
|
||||
function txnAsPromise(txn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
txn.oncomplete = function(event) {
|
||||
resolve(event);
|
||||
};
|
||||
txn.onerror = function(event) {
|
||||
reject(event);
|
||||
reject(event.target.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function promiseifyRequest(req) {
|
||||
function reqAsEventPromise(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = function(event) {
|
||||
resolve(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
|
||||
*
|
||||
@@ -104,6 +129,7 @@ const LocalIndexedDBStoreBackend = function LocalIndexedDBStoreBackend(
|
||||
this.db = null;
|
||||
this._disconnected = true;
|
||||
this._syncAccumulator = new SyncAccumulator();
|
||||
this._isNewlyCreated = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -134,8 +160,15 @@ LocalIndexedDBStoreBackend.prototype = {
|
||||
`LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`,
|
||||
);
|
||||
if (oldVersion < 1) { // The database did not previously exist.
|
||||
this._isNewlyCreated = true;
|
||||
createDatabase(db);
|
||||
}
|
||||
if (oldVersion < 2) {
|
||||
upgradeSchemaV2(db);
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
upgradeSchemaV3(db);
|
||||
}
|
||||
// Expand as needed.
|
||||
};
|
||||
|
||||
@@ -148,7 +181,7 @@ LocalIndexedDBStoreBackend.prototype = {
|
||||
console.log(
|
||||
`LocalIndexedDBStoreBackend.connect: awaiting connection...`,
|
||||
);
|
||||
return promiseifyRequest(req).then((ev) => {
|
||||
return reqAsEventPromise(req).then((ev) => {
|
||||
console.log(
|
||||
`LocalIndexedDBStoreBackend.connect: connected`,
|
||||
);
|
||||
@@ -163,6 +196,10 @@ LocalIndexedDBStoreBackend.prototype = {
|
||||
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
|
||||
@@ -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
|
||||
* to prevent mixing data between accounts.
|
||||
@@ -284,7 +439,7 @@ LocalIndexedDBStoreBackend.prototype = {
|
||||
roomsData: roomsData,
|
||||
groupsData: groupsData,
|
||||
}); // put == UPSERT
|
||||
return promiseifyTxn(txn);
|
||||
return txnAsPromise(txn);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -301,7 +456,7 @@ LocalIndexedDBStoreBackend.prototype = {
|
||||
for (let i = 0; i < accountData.length; i++) {
|
||||
store.put(accountData[i]); // put == UPSERT
|
||||
}
|
||||
return promiseifyTxn(txn);
|
||||
return txnAsPromise(txn);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -323,7 +478,7 @@ LocalIndexedDBStoreBackend.prototype = {
|
||||
event: tuple[1],
|
||||
}); // 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;
|
||||
|
||||
@@ -65,7 +65,10 @@ RemoteIndexedDBStoreBackend.prototype = {
|
||||
clearDatabase: function() {
|
||||
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
|
||||
* 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]);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -147,7 +184,9 @@ RemoteIndexedDBStoreBackend.prototype = {
|
||||
if (msg.command == 'cmd_success') {
|
||||
def.resolve(msg.result);
|
||||
} else {
|
||||
def.reject(msg.error);
|
||||
const error = new Error(msg.error.message);
|
||||
error.name = msg.error.name;
|
||||
def.reject(error);
|
||||
}
|
||||
} else {
|
||||
console.warn("Unrecognised message from worker: " + msg);
|
||||
|
||||
@@ -67,6 +67,9 @@ class IndexedDBStoreWorker {
|
||||
case 'connect':
|
||||
prom = this.backend.connect();
|
||||
break;
|
||||
case 'isNewlyCreated':
|
||||
prom = this.backend.isNewlyCreated();
|
||||
break;
|
||||
case 'clearDatabase':
|
||||
prom = this.backend.clearDatabase().then((result) => {
|
||||
// This returns special classes which can't be cloned
|
||||
@@ -92,10 +95,25 @@ class IndexedDBStoreWorker {
|
||||
case 'getNextBatchToken':
|
||||
prom = this.backend.getNextBatchToken();
|
||||
break;
|
||||
case 'getOutOfBandMembers':
|
||||
prom = this.backend.getOutOfBandMembers(msg.args[0]);
|
||||
break;
|
||||
case 'clearOutOfBandMembers':
|
||||
prom = this.backend.clearOutOfBandMembers(msg.args[0]);
|
||||
break;
|
||||
case 'setOutOfBandMembers':
|
||||
prom = this.backend.setOutOfBandMembers(msg.args[0], msg.args[1]);
|
||||
break;
|
||||
case 'getClientOptions':
|
||||
prom = this.backend.getClientOptions();
|
||||
break;
|
||||
case 'storeClientOptions':
|
||||
prom = this.backend.storeClientOptions(msg.args[0]);
|
||||
break;
|
||||
}
|
||||
|
||||
if (prom === undefined) {
|
||||
postMessage({
|
||||
this.postMessage({
|
||||
command: 'cmd_fail',
|
||||
seq: msg.seq,
|
||||
// Can't be an Error because they're not structured cloneable
|
||||
@@ -117,7 +135,10 @@ class IndexedDBStoreWorker {
|
||||
command: 'cmd_fail',
|
||||
seq: msg.seq,
|
||||
// Just send a string because Error objects aren't cloneable
|
||||
error: "Error running command",
|
||||
error: {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,6 +146,11 @@ IndexedDBStore.prototype.getSavedSync = function() {
|
||||
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
|
||||
* for this sync, otherwise null.
|
||||
@@ -219,4 +224,39 @@ IndexedDBStore.prototype.setSyncData = function(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;
|
||||
|
||||
@@ -52,6 +52,10 @@ module.exports.MatrixInMemoryStore = function MatrixInMemoryStore(opts) {
|
||||
// type : content
|
||||
};
|
||||
this.localStorage = opts.localStorage;
|
||||
this._oobMembers = {
|
||||
// roomId: [member events]
|
||||
};
|
||||
this._clientOptions = {};
|
||||
};
|
||||
|
||||
module.exports.MatrixInMemoryStore.prototype = {
|
||||
@@ -64,6 +68,10 @@ module.exports.MatrixInMemoryStore.prototype = {
|
||||
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.
|
||||
@@ -377,4 +385,35 @@ module.exports.MatrixInMemoryStore.prototype = {
|
||||
};
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,6 +32,11 @@ function StubStore() {
|
||||
|
||||
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.
|
||||
* @return {string}
|
||||
@@ -264,6 +269,26 @@ StubStore.prototype = {
|
||||
deleteAllData: function() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
getOutOfBandMembers: function() {
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
|
||||
setOutOfBandMembers: function() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
clearOutOfBandMembers: function() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
getClientOptions: function() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
storeClientOptions: function() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
/** Stub Store class. */
|
||||
|
||||
@@ -63,6 +63,11 @@ class SyncAccumulator {
|
||||
// { event: $event, token: null|token },
|
||||
// ...
|
||||
// ],
|
||||
// _summary: {
|
||||
// m.heroes: [ $user_id ],
|
||||
// m.joined_member_count: $count,
|
||||
// m.invited_member_count: $count
|
||||
// },
|
||||
// _accountData: { $event_type: json },
|
||||
// _unreadNotifications: { ... unread_notifications JSON ... },
|
||||
// _readReceipts: { $user_id: { data: $json, eventId: $event_id }}
|
||||
@@ -242,6 +247,7 @@ class SyncAccumulator {
|
||||
_timeline: [],
|
||||
_accountData: Object.create(null),
|
||||
_unreadNotifications: {},
|
||||
_summary: {},
|
||||
_readReceipts: {},
|
||||
};
|
||||
}
|
||||
@@ -258,6 +264,17 @@ class SyncAccumulator {
|
||||
if (data.unread_notifications) {
|
||||
currentData._unreadNotifications = data.unread_notifications;
|
||||
}
|
||||
if (data.summary) {
|
||||
const HEROES_KEY = "m.heroes";
|
||||
const INVITED_COUNT_KEY = "m.invited_member_count";
|
||||
const JOINED_COUNT_KEY = "m.joined_member_count";
|
||||
|
||||
const acc = currentData._summary;
|
||||
const sum = data.summary;
|
||||
acc[HEROES_KEY] = sum[HEROES_KEY] || acc[HEROES_KEY];
|
||||
acc[JOINED_COUNT_KEY] = sum[JOINED_COUNT_KEY] || acc[JOINED_COUNT_KEY];
|
||||
acc[INVITED_COUNT_KEY] = sum[INVITED_COUNT_KEY] || acc[INVITED_COUNT_KEY];
|
||||
}
|
||||
|
||||
if (data.ephemeral && data.ephemeral.events) {
|
||||
data.ephemeral.events.forEach((e) => {
|
||||
@@ -428,6 +445,7 @@ class SyncAccumulator {
|
||||
prev_batch: null,
|
||||
},
|
||||
unread_notifications: roomData._unreadNotifications,
|
||||
summary: roomData._summary,
|
||||
};
|
||||
// Add account data
|
||||
Object.keys(roomData._accountData).forEach((evType) => {
|
||||
|
||||
160
src/sync.js
160
src/sync.js
@@ -33,6 +33,8 @@ const utils = require("./utils");
|
||||
const Filter = require("./filter");
|
||||
const EventTimeline = require("./models/event-timeline");
|
||||
|
||||
import {InvalidStoreError} from './errors';
|
||||
|
||||
const DEBUG = true;
|
||||
|
||||
// /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._currentSyncRequest = null;
|
||||
this._syncState = null;
|
||||
this._syncStateData = null; // additional data (eg. error object for failed sync)
|
||||
this._catchingUp = false;
|
||||
this._running = false;
|
||||
this._keepAliveTimer = null;
|
||||
this._connectionReturnedDefer = null;
|
||||
this._notifEvents = []; // accumulator of sync events in the current sync response
|
||||
this._failedSyncCount = 0; // Number of consecutive failed /sync requests
|
||||
this._storeIsInvalid = false; // flag set if the store needs to be cleared before we can start
|
||||
|
||||
if (client.getNotifTimelineSet()) {
|
||||
client.reEmitter.reEmit(client.getNotifTimelineSet(),
|
||||
@@ -112,7 +116,8 @@ function SyncApi(client, opts) {
|
||||
*/
|
||||
SyncApi.prototype.createRoom = function(roomId) {
|
||||
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,
|
||||
timelineSupport: client.timelineSupport,
|
||||
});
|
||||
@@ -121,6 +126,7 @@ SyncApi.prototype.createRoom = function(roomId) {
|
||||
"Room.timelineReset",
|
||||
"Room.localEchoUpdated",
|
||||
"Room.accountData",
|
||||
"Room.myMembership",
|
||||
]);
|
||||
this._registerStateListeners(room);
|
||||
return room;
|
||||
@@ -231,7 +237,7 @@ SyncApi.prototype.syncLeftRooms = function() {
|
||||
|
||||
self._processRoomEvents(room, stateEvents, timelineEvents);
|
||||
|
||||
room.recalculate(client.credentials.userId);
|
||||
room.recalculate();
|
||||
client.store.storeRoom(room);
|
||||
client.emit("Room", room);
|
||||
|
||||
@@ -302,7 +308,7 @@ SyncApi.prototype.peek = function(roomId) {
|
||||
peekRoom.currentState.setStateEvents(stateEvents);
|
||||
|
||||
self._resolveInvites(peekRoom);
|
||||
peekRoom.recalculate(self.client.credentials.userId);
|
||||
peekRoom.recalculate();
|
||||
|
||||
// roll backwards to diverge old state. addEventsToTimeline
|
||||
// will overwrite the pagination token, so make sure it overwrites
|
||||
@@ -396,6 +402,18 @@ SyncApi.prototype.getSyncState = function() {
|
||||
return this._syncState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the additional data object associated with
|
||||
* the current sync state, or null if there is no
|
||||
* such data.
|
||||
* Sync errors, if available, are put in the 'error' key of
|
||||
* this object.
|
||||
* @return {?Object}
|
||||
*/
|
||||
SyncApi.prototype.getSyncStateData = function() {
|
||||
return this._syncStateData;
|
||||
};
|
||||
|
||||
SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise, err) {
|
||||
// Wait for the saved sync to complete - we send the pushrules and filter requests
|
||||
// before the saved sync has finished so they can run in parallel, but only process
|
||||
@@ -407,6 +425,26 @@ SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise,
|
||||
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
|
||||
*/
|
||||
@@ -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
|
||||
// them from /sync.
|
||||
// 2) We need to get/create a filter which we can use for /sync.
|
||||
// 3) We need to check the lazy loading option matches what was used in the
|
||||
// stored sync. If it doesn't, we can't use the stored sync.
|
||||
|
||||
async function getPushRules() {
|
||||
try {
|
||||
@@ -443,9 +483,47 @@ SyncApi.prototype.sync = function() {
|
||||
getPushRules();
|
||||
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() {
|
||||
let filter;
|
||||
if (self.opts.filter) {
|
||||
@@ -573,7 +651,12 @@ SyncApi.prototype._syncFromCache = async function(savedSync) {
|
||||
console.error("Error processing cached sync", e.stack || e);
|
||||
}
|
||||
|
||||
this._updateSyncState("PREPARED", syncEventData);
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -763,9 +846,20 @@ SyncApi.prototype._onSyncError = function(err, syncOptions) {
|
||||
// fails, since long lived HTTP connections will
|
||||
// go away sometimes and we shouldn't treat this as
|
||||
// erroneous. We set the state to 'reconnecting'
|
||||
// instead, so that clients can onserve this state
|
||||
// instead, so that clients can observe this state
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -774,6 +868,7 @@ SyncApi.prototype._onSyncError = function(err, syncOptions) {
|
||||
this._updateSyncState(
|
||||
this._failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ?
|
||||
"ERROR" : "RECONNECTING",
|
||||
{ error: err },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -809,6 +904,11 @@ SyncApi.prototype._processSyncResponse = async function(
|
||||
// state: { events: [] },
|
||||
// timeline: { events: [], prev_batch: $token, limited: true },
|
||||
// ephemeral: { events: [] },
|
||||
// summary: {
|
||||
// m.heroes: [ $user_id ],
|
||||
// m.joined_member_count: $count,
|
||||
// m.invited_member_count: $count
|
||||
// },
|
||||
// account_data: { events: [] },
|
||||
// unread_notifications: {
|
||||
// highlight_count: 0,
|
||||
@@ -947,9 +1047,11 @@ SyncApi.prototype._processSyncResponse = async function(
|
||||
const room = inviteObj.room;
|
||||
const stateEvents =
|
||||
self._mapSyncEventsFormat(inviteObj.invite_state, room);
|
||||
|
||||
room.updateMyMembership("invite");
|
||||
self._processRoomEvents(room, stateEvents);
|
||||
if (inviteObj.isBrandNewRoom) {
|
||||
room.recalculate(client.credentials.userId);
|
||||
room.recalculate();
|
||||
client.store.storeRoom(room);
|
||||
client.emit("Room", room);
|
||||
}
|
||||
@@ -976,6 +1078,8 @@ SyncApi.prototype._processSyncResponse = async function(
|
||||
);
|
||||
}
|
||||
|
||||
room.updateMyMembership("join");
|
||||
|
||||
joinObj.timeline = joinObj.timeline || {};
|
||||
|
||||
if (joinObj.isBrandNewRoom) {
|
||||
@@ -1040,6 +1144,13 @@ SyncApi.prototype._processSyncResponse = async function(
|
||||
|
||||
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?
|
||||
// It feels like that for symmetry with room.addAccountData()
|
||||
// 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
|
||||
room.addAccountData(accountDataEvents);
|
||||
|
||||
room.recalculate(client.credentials.userId);
|
||||
room.recalculate();
|
||||
if (joinObj.isBrandNewRoom) {
|
||||
client.store.storeRoom(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) {
|
||||
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);
|
||||
@@ -1083,10 +1204,12 @@ SyncApi.prototype._processSyncResponse = async function(
|
||||
const accountDataEvents =
|
||||
self._mapSyncEventsFormat(leaveObj.account_data);
|
||||
|
||||
room.updateMyMembership("leave");
|
||||
|
||||
self._processRoomEvents(room, stateEvents, timelineEvents);
|
||||
room.addAccountData(accountDataEvents);
|
||||
|
||||
room.recalculate(client.credentials.userId);
|
||||
room.recalculate();
|
||||
if (leaveObj.isBrandNewRoom) {
|
||||
client.store.storeRoom(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
|
||||
* 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;
|
||||
function success() {
|
||||
clearTimeout(self._keepAliveTimer);
|
||||
if (self._connectionReturnedDefer) {
|
||||
self._connectionReturnedDefer.resolve();
|
||||
self._connectionReturnedDefer.resolve(connDidFail);
|
||||
self._connectionReturnedDefer = null;
|
||||
}
|
||||
}
|
||||
@@ -1198,7 +1324,7 @@ SyncApi.prototype._pokeKeepAlive = function() {
|
||||
).done(function() {
|
||||
success();
|
||||
}, 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
|
||||
// support /versions: point is, we're getting a response.
|
||||
// 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.
|
||||
self._keepAliveTimer = setTimeout(success, 2000);
|
||||
} else {
|
||||
connDidFail = true;
|
||||
self._keepAliveTimer = setTimeout(
|
||||
self._pokeKeepAlive.bind(self),
|
||||
self._pokeKeepAlive.bind(self, connDidFail),
|
||||
5000 + Math.floor(Math.random() * 5000),
|
||||
);
|
||||
// A keepalive has failed, so we emit the
|
||||
@@ -1215,7 +1342,7 @@ SyncApi.prototype._pokeKeepAlive = function() {
|
||||
// first failure).
|
||||
// Note we do this after setting the timer:
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
@@ -1376,7 +1503,7 @@ SyncApi.prototype._processRoomEvents = function(room, stateEventList,
|
||||
// a recalculation (like m.room.name) we won't recalculate until we've
|
||||
// finished adding all the events, which will cause the notification to have
|
||||
// the old room name rather than the new one.
|
||||
room.recalculate(this.client.credentials.userId);
|
||||
room.recalculate();
|
||||
|
||||
// If the timeline wasn't empty, we process the state events here: they're
|
||||
// defined as updates to the state before the start of the timeline, so this
|
||||
@@ -1451,6 +1578,7 @@ SyncApi.prototype._getGuestFilter = function() {
|
||||
SyncApi.prototype._updateSyncState = function(newState, data) {
|
||||
const old = this._syncState;
|
||||
this._syncState = newState;
|
||||
this._syncStateData = data;
|
||||
this.client.emit("sync", this._syncState, old, data);
|
||||
};
|
||||
|
||||
|
||||
24
src/utils.js
24
src/utils.js
@@ -675,3 +675,27 @@ module.exports.removeHiddenChars = function(str) {
|
||||
return unhomoglyph(str.normalize('NFD').replace(removeHiddenCharsRegex, ''));
|
||||
};
|
||||
const removeHiddenCharsRegex = /[\u200B-\u200D\u0300-\u036f\uFEFF\s]/g;
|
||||
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
module.exports.escapeRegExp = escapeRegExp;
|
||||
|
||||
module.exports.globToRegexp = function(glob, extended) {
|
||||
extended = typeof(extended) === 'boolean' ? extended : true;
|
||||
// From
|
||||
// https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
|
||||
// Because micromatch is about 130KB with dependencies,
|
||||
// and minimatch is not much better.
|
||||
let pat = escapeRegExp(glob);
|
||||
pat = pat.replace(/\\\*/g, '.*');
|
||||
pat = pat.replace(/\?/g, '.');
|
||||
if (extended) {
|
||||
pat = pat.replace(/\\\[(!|)(.*)\\]/g, function(match, p1, p2, offset, string) {
|
||||
const first = p1 && '^' || '';
|
||||
const second = p2.replace(/\\\-/, '-');
|
||||
return '[' + first + second + ']';
|
||||
});
|
||||
}
|
||||
return pat;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user